Entegrasyon örnekleri
RESMI_AKIS_V2: Pending Login → challenge → Agent WS
(Mode A Browser cert+PIN · Mode B Agent native) →
agent-verify → Login Result accepted → LOGIN_SUCCESS.
SDK JWT, imza veya sertifika döndürmez.
Akış
1. Browser → Müşteri backend: Pending Login → correlation_id
2. Browser → api.ksign.tr: POST challenge (Publishable Key + Origin + correlation_id)
← agent_ticket + challenge (verify_token YOK, JWT YOK)
3. Browser → Agent: GET /ping → WS LOGIN_START
Mode A: CERTIFICATE_LIST → SELECT_CERTIFICATE → PIN_SUBMIT
Mode B: pin_mode=agent — Agent native cert + PIN (Browser cert/PIN görmez)
4. Agent → api: POST …/agent-verify (signature + certificate — Browser taşımaz)
5. api → Müşteri: Login Result (KSign JWT + doğrulanmış kimlik)
6. Müşteri → api: { success, status: "accepted", correlation_id }
7. api → Agent: Login Completed → WS LOGIN_SUCCESS (JWT yok)
8. Müşteri: kendi uygulama oturumu (cookie / session — KSign dışı)
Kategori
RESMI_AKIS_V2 §9 — Mode A (Browser cert+PIN) ve Mode B (Agent native). Müşteri Allowed Origin üzerinde host edin.
Araç
Mode A/B seçici + cdn.ksign.tr — ekstra build yok.
cdn-login.html ham dosya
<!DOCTYPE html>
<html lang="tr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>KSign — Browser Login Örneği (Mode A / B)</title>
<style>
:root { color-scheme: light dark; font-family: "Segoe UI", system-ui, sans-serif; line-height: 1.5; }
body { max-width: 42rem; margin: 2rem auto; padding: 0 1rem; }
h1 { font-size: 1.35rem; margin-bottom: 0.25rem; }
p.sub { color: #666; margin-top: 0; }
label { display: block; font-weight: 600; margin-top: 1rem; }
input, button {
width: 100%; box-sizing: border-box; margin-top: 0.35rem;
padding: 0.55rem 0.65rem; font: inherit;
}
.modes { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin: 1rem 0; }
.mode {
text-align: left; padding: 0.65rem 0.75rem; border-radius: 8px;
border: 1px solid #8884; background: transparent; cursor: pointer;
}
.mode.active { border-color: #3d8bfd; background: color-mix(in srgb, #3d8bfd 14%, transparent); }
.mode strong { display: block; font-size: 0.95rem; }
.mode small { display: block; color: #666; font-weight: 400; margin-top: 0.2rem; }
.actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; }
button { width: auto; min-width: 9rem; cursor: pointer; }
button:disabled { opacity: 0.55; cursor: not-allowed; }
.panel {
display: none; margin-top: 1rem; padding: 0.85rem 1rem;
border: 1px solid #8884; border-radius: 8px;
}
.panel.visible { display: block; }
.cert-list { display: grid; gap: 0.45rem; margin-top: 0.5rem; }
.cert {
text-align: left; padding: 0.55rem 0.65rem; border-radius: 6px;
border: 1px solid #8884; background: transparent; cursor: pointer; width: 100%;
}
.cert .subj { font-weight: 600; }
.cert .meta { font-size: 0.82rem; color: #666; margin-top: 0.15rem; }
pre {
background: #111; color: #eee; padding: 1rem; border-radius: 8px;
overflow: auto; min-height: 7rem; white-space: pre-wrap; word-break: break-word;
}
.ok { color: #0a7; }
.err { color: #c33; }
a { color: #3d8bfd; }
.note {
margin-top: 1rem; padding: 0.75rem 0.9rem; border-radius: 8px;
background: color-mix(in srgb, #3d8bfd 12%, transparent);
font-size: 0.9rem;
}
.hint { font-size: 0.88rem; color: #666; margin: 0.35rem 0 0; }
</style>
</head>
<body>
<p><a href="/examples/">← Örnekler</a></p>
<h1>KSign Browser Login</h1>
<p class="sub">RESMI_AKIS_V2 §9 — Mode A (Browser) veya Mode B (Agent native)</p>
<div class="note">
Müşteri <strong>Allowed Origin</strong> üzerinde host edin. SDK JWT döndürmez;
oturum Login Result <code>accepted</code> sonrası müşteri backend’de oluşur.
</div>
<div class="modes" role="group" aria-label="PIN mode">
<button type="button" class="mode active" id="modeBrowser" aria-pressed="true">
<strong>Mode A — Browser</strong>
<small>CERTIFICATE_LIST → seçim → PIN_SUBMIT</small>
</button>
<button type="button" class="mode" id="modeAgent" aria-pressed="false">
<strong>Mode B — Agent</strong>
<small>Native sertifika + PIN — tarayıcıda PIN yok</small>
</button>
</div>
<label for="backendUrl">KSign API</label>
<input id="backendUrl" type="url" value="https://api.ksign.tr" />
<label for="publishableKey">Publishable Key</label>
<input id="publishableKey" type="text" value="ksign_pk_test_019f49100001700080000000000100000000000000000001" autocomplete="off" />
<label for="pendingUrl">Pending Login URL (müşteri backend, opsiyonel)</label>
<input id="pendingUrl" type="url" placeholder="https://app.example/api/esign/pending" />
<label for="correlationId">correlation_id</label>
<input id="correlationId" type="text" placeholder="Pending’den veya elle UUID" autocomplete="off" />
<div id="certPanel" class="panel">
<strong>Sertifika seçin</strong>
<p class="hint">Yalnız özet bilgi — DER / private key tarayıcıya gelmez.</p>
<div id="certList" class="cert-list"></div>
</div>
<div id="pinPanel" class="panel">
<strong id="pinTitle">Token PIN</strong>
<p class="hint" id="pinHint">PIN yalnızca Agent WebSocket PIN_SUBMIT ile gider.</p>
<label for="pin">PIN</label>
<input id="pin" type="password" autocomplete="off" />
<div class="actions">
<button id="btnPinSubmit" type="button">PIN gönder</button>
</div>
</div>
<div id="agentPanel" class="panel">
<strong>Agent penceresi</strong>
<p class="hint" id="agentHint">
KSign Agent’ta sertifika ve PIN’i onaylayın. Bu sayfada sertifika listesi ve PIN alanı kullanılmaz.
</p>
</div>
<div class="actions">
<button id="btnPending" type="button">Pending Login</button>
<button id="btnDetect" type="button">Detect Agent</button>
<button id="btnLogin" type="button">Login with e-Sign</button>
</div>
<label for="output">Sonuç</label>
<pre id="output">Hazır.</pre>
<script src="https://cdn.ksign.tr/browser.min.js"></script>
<script>
(function () {
const $ = (id) => document.getElementById(id);
const out = $('output');
let pinMode = 'browser';
let certResolve = null;
let pinResolve = null;
let pinReject = null;
function log(msg, kind) {
out.textContent = (kind === 'err' ? '[HATA] ' : kind === 'ok' ? '[OK] ' : '') + msg;
out.className = kind === 'err' ? 'err' : kind === 'ok' ? 'ok' : '';
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
function hidePanels() {
$('certPanel').classList.remove('visible');
$('pinPanel').classList.remove('visible');
$('agentPanel').classList.remove('visible');
$('certList').innerHTML = '';
$('pin').value = '';
certResolve = null;
if (pinReject) {
const reject = pinReject;
pinResolve = null;
pinReject = null;
reject(new Error('login cancelled'));
}
}
function setPinMode(mode) {
pinMode = mode === 'agent' ? 'agent' : 'browser';
$('modeBrowser').classList.toggle('active', pinMode === 'browser');
$('modeAgent').classList.toggle('active', pinMode === 'agent');
$('modeBrowser').setAttribute('aria-pressed', pinMode === 'browser' ? 'true' : 'false');
$('modeAgent').setAttribute('aria-pressed', pinMode === 'agent' ? 'true' : 'false');
hidePanels();
}
function showCertificates(certificates) {
return new Promise((resolve, reject) => {
certResolve = resolve;
const list = $('certList');
list.innerHTML = '';
if (!certificates.length) {
reject(new Error('Uygun imza sertifikası yok'));
return;
}
certificates.forEach((c) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'cert';
btn.innerHTML =
'<div class="subj">' + escapeHtml(c.subject || c.certificateId) + '</div>' +
'<div class="meta"><code>' + escapeHtml(c.certificateId) + '</code></div>';
btn.addEventListener('click', () => {
list.querySelectorAll('button').forEach((b) => { b.disabled = true; });
if (certResolve) {
const r = certResolve;
certResolve = null;
r(c.certificateId);
}
});
list.appendChild(btn);
});
$('certPanel').classList.add('visible');
log('CERTIFICATE_LIST — sertifika seçin');
});
}
function requestPin(reason) {
return new Promise((resolve, reject) => {
pinResolve = resolve;
pinReject = reject;
$('pinPanel').classList.add('visible');
$('pin').value = '';
if (reason && reason !== 'pin_required') {
$('pinTitle').textContent = 'PIN yeniden';
$('pinHint').textContent = 'PIN reddedildi (' + reason + '). Challenge tüketilmez.';
log('PIN_RETRY: ' + reason, 'err');
} else {
$('pinTitle').textContent = 'Token PIN';
$('pinHint').textContent = 'PIN yalnızca Agent WS PIN_SUBMIT ile gider.';
}
$('pin').focus();
});
}
$('modeBrowser').addEventListener('click', () => setPinMode('browser'));
$('modeAgent').addEventListener('click', () => setPinMode('agent'));
$('btnPending').addEventListener('click', async () => {
try {
const url = $('pendingUrl').value.trim();
if (!url) {
$('correlationId').value = crypto.randomUUID();
log('Yerel correlation_id üretildi.\n' + $('correlationId').value, 'ok');
return;
}
const res = await fetch(url, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
credentials: 'include',
body: '{}',
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.message || ('HTTP ' + res.status));
const id = data.correlation_id || data.correlationId;
if (!id) throw new Error('correlation_id yok');
$('correlationId').value = id;
log('Pending OK\n' + JSON.stringify(data, null, 2), 'ok');
} catch (e) {
log(e instanceof Error ? e.message : String(e), 'err');
}
});
$('btnDetect').addEventListener('click', async () => {
try {
if (!window.KSignSDK) throw new Error('KSignSDK yüklenemedi');
const result = await window.KSignSDK.discoverAgent();
log(
'Agent bulundu\npingUrl: ' + result.pingUrl +
'\nagentBaseUrl: ' + result.agentBaseUrl +
'\nwsLoginUrl: ' + result.wsLoginUrl,
'ok'
);
} catch (e) {
log(e instanceof Error ? e.message : String(e), 'err');
}
});
$('btnPinSubmit').addEventListener('click', () => {
const pin = $('pin').value;
if (!pin) {
log('PIN boş', 'err');
return;
}
if (pinResolve) {
const r = pinResolve;
pinResolve = null;
pinReject = null;
$('pinPanel').classList.remove('visible');
r(pin);
}
});
$('btnLogin').addEventListener('click', async () => {
$('btnLogin').disabled = true;
hidePanels();
try {
if (!window.KSignSDK) throw new Error('KSignSDK yüklenemedi');
const publishableKey = $('publishableKey').value.trim();
const backendUrl = $('backendUrl').value.trim().replace(/\/+$/, '');
const correlationId = $('correlationId').value.trim() || crypto.randomUUID();
if (!publishableKey) throw new Error('publishableKey gerekli');
if (!correlationId) throw new Error('correlation_id gerekli');
if (!$('correlationId').value.trim()) $('correlationId').value = correlationId;
const client = new window.KSignSDK.KSign({ publishableKey, backendUrl });
const loginOptions = {
correlationId,
pinMode,
onEvent: (ev) => {
console.log('[ksign]', ev.type, ev);
if (ev.type === 'READY' && pinMode === 'agent') {
$('agentPanel').classList.add('visible');
log('READY — Agent native UI bekleniyor');
} else if (ev.type === 'PIN_ACCEPTED' || ev.type === 'SIGNING' || ev.type === 'QUEUE_WAIT') {
hidePanels();
} else if (ev.type === 'PIN_RETRY' && pinMode === 'agent') {
$('agentPanel').classList.add('visible');
$('agentHint').textContent =
'Yanlış PIN — Agent penceresinde tekrar deneyin (challenge tüketilmez).';
log('PIN_RETRY (Mode B): ' + (ev.reason || 'wrong_pin'), 'err');
}
},
};
if (pinMode === 'browser') {
loginOptions.onSelectCertificate = (certs) => showCertificates(certs);
loginOptions.onRequestPin = () => requestPin();
loginOptions.onPinRetry = (reason) => requestPin(reason);
}
const result = await client.login(loginOptions);
hidePanels();
log(
'Login tamamlandı (JWT yok)\n' + JSON.stringify(result, null, 2) +
'\n\nAkış: challenge → WS → agent-verify → Login Result → accepted → LOGIN_SUCCESS' +
'\nUygulama oturumu: müşteri backend.',
'ok'
);
} catch (e) {
hidePanels();
const body = e && e.code
? { code: e.code, message: e.message, challengeId: e.challengeId, backendCode: e.backendCode }
: String(e);
log('Login başarısız\n' + JSON.stringify(body, null, 2), 'err');
} finally {
$('btnLogin').disabled = false;
}
});
log(
'Origin: ' + location.origin + '\n' +
'Mode A: CERTIFICATE_LIST → SELECT_CERTIFICATE → PIN_SUBMIT\n' +
'Mode B: pinMode=agent — Agent native cert + PIN\n' +
'Kaynak: /examples/browser/login.js'
);
})();
</script>
</body>
</html>
loginModeA / loginModeB — RESMI_AKIS_V2 §9.
login.js ham dosya
/**
* KSign Browser SDK v2.3 — Mode A / Mode B login orchestration
*
* Referans: docs/RESMI_AKIS_V2.md §9
*
* Prerequisites:
* <script src="https://cdn.ksign.tr/browser.min.js"></script>
*
* SDK JWT, imza veya sertifika döndürmez.
* Uygulama oturumu müşteri backend Login Result `accepted` sonrası oluşur.
*/
function assertSDK() {
if (!globalThis.KSignSDK?.KSign) {
throw new Error('KSignSDK yüklenemedi — cdn.ksign.tr/browser.min.js');
}
}
function createClient(opts) {
assertSDK();
if (!opts?.publishableKey) throw new Error('publishableKey gerekli');
return new globalThis.KSignSDK.KSign({
publishableKey: opts.publishableKey,
backendUrl: (opts.backendUrl || 'https://api.ksign.tr').replace(/\/+$/, ''),
});
}
/**
* Mode A — Browser Mode (RESMI_AKIS_V2 §9 A)
*
* Agent → CERTIFICATE_LIST; Browser → SELECT_CERTIFICATE + PIN_SUBMIT.
* PIN yalnızca yerel Agent WebSocket oturumuna gider.
*
* @param {object} opts
* @param {string} opts.publishableKey
* @param {string} opts.correlationId — müşteri Pending Login
* @param {string} [opts.backendUrl]
* @param {(certificates: Array<{ certificateId: string, subject?: string }>) => Promise<string>} opts.onSelectCertificate
* @param {(reason?: string) => Promise<string>} opts.onRequestPin
* @param {(reason?: string) => Promise<string>} [opts.onPinRetry]
* @param {(event: { type: string }) => void} [opts.onEvent]
*/
export async function loginModeA(opts) {
if (!opts?.correlationId) throw new Error('correlationId gerekli (Pending Login)');
if (typeof opts.onSelectCertificate !== 'function') {
throw new Error('Mode A: onSelectCertificate gerekli');
}
if (typeof opts.onRequestPin !== 'function') {
throw new Error('Mode A: onRequestPin gerekli');
}
const client = createClient(opts);
return client.login({
correlationId: opts.correlationId,
pinMode: 'browser',
onSelectCertificate: opts.onSelectCertificate,
onRequestPin: opts.onRequestPin,
onPinRetry: opts.onPinRetry || opts.onRequestPin,
onEvent: opts.onEvent,
});
}
/**
* Mode B — Agent Mode (RESMI_AKIS_V2 §9 B)
*
* Agent native UI açılır; Browser sertifika listesi ve PIN görmez.
* LOGIN_START pin_mode=agent ile gider.
*
* @param {object} opts
* @param {string} opts.publishableKey
* @param {string} opts.correlationId
* @param {string} [opts.backendUrl]
* @param {(event: { type: string, reason?: string }) => void} [opts.onEvent]
*/
export async function loginModeB(opts) {
if (!opts?.correlationId) throw new Error('correlationId gerekli (Pending Login)');
const client = createClient(opts);
return client.login({
correlationId: opts.correlationId,
pinMode: 'agent',
onEvent: opts.onEvent,
});
}
/**
* @deprecated Tek alan PIN + opsiyonel thumbprint — hızlı test için.
* Üretimde Mode A için loginModeA (callback UI) veya Mode B için loginModeB kullanın.
*/
export async function loginWithESign(opts) {
if (!opts?.correlationId) throw new Error('correlationId gerekli (Pending Login)');
if (!opts?.pin) throw new Error('PIN gerekli');
const client = createClient(opts);
return client.login({
pin: opts.pin,
correlationId: opts.correlationId,
certificateId: opts.certificateId || undefined,
onEvent: opts.onEvent,
});
}
/**
* Müşteri backend — Pending Login → correlation_id
* @param {string} pendingUrl e.g. https://app.example/api/esign/pending
*/
export async function startPendingLogin(pendingUrl) {
const res = await fetch(pendingUrl, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
credentials: 'include',
body: '{}',
});
if (!res.ok) {
throw new Error('Pending Login başarısız: HTTP ' + res.status);
}
const data = await res.json();
const correlationId = data.correlation_id || data.correlationId;
if (!correlationId) {
throw new Error('Pending Login yanıtında correlation_id yok');
}
return { correlationId, ...data };
}
Svelte 5 — Mode A/B toggle + Pending / Login Result. npm install @ksign/browser
Araç
ESignLogin.svelte ham dosya
<script lang="ts">
/**
* KSign Browser SDK v2.3 — Svelte 5 login örneği
*
* RESMI_AKIS_V2 §9:
* Mode A (browser) — CERTIFICATE_LIST + PIN_SUBMIT
* Mode B (agent) — Agent native UI; tarayıcı cert/PIN görmez
*
* npm: npm install @ksign/browser
* SDK JWT / signature / certificate döndürmez.
*/
import { KSign, discoverAgent } from '@ksign/browser';
import type { CertificateSummary, LoginResult, LoginSessionEvent } from '@ksign/browser';
type PinMode = 'browser' | 'agent';
interface Props {
publishableKey: string;
backendUrl?: string;
pendingUrl?: string;
/** Başlangıç modu — kullanıcı arayüzden değiştirebilir */
initialPinMode?: PinMode;
}
let {
publishableKey,
backendUrl = 'https://api.ksign.tr',
pendingUrl = '',
initialPinMode = 'browser'
}: Props = $props();
let pinMode = $state<PinMode>(initialPinMode);
let correlationId = $state('');
let busy = $state(false);
let logText = $state('Hazır.');
let logKind = $state<'info' | 'ok' | 'err'>('info');
let lastEvent = $state('');
let loginResult = $state<LoginResult | null>(null);
let certPanel = $state(false);
let pinPanel = $state(false);
let agentPanel = $state(false);
let certificates = $state<CertificateSummary[]>([]);
let pin = $state('');
let pinTitle = $state('Token PIN');
let pinHint = $state('PIN yalnızca Agent WebSocket PIN_SUBMIT ile gider.');
let agentHint = $state(
'KSign Agent penceresinde sertifika ve PIN onaylayın. Bu bileşende cert/PIN alanı yok.'
);
let certResolve: ((id: string) => void) | null = null;
let pinResolve: ((value: string) => void) | null = null;
let pinReject: ((err: Error) => void) | null = null;
function setLog(message: string, kind: 'info' | 'ok' | 'err' = 'info') {
logText = message;
logKind = kind;
}
function resetInteractive() {
certPanel = false;
pinPanel = false;
agentPanel = false;
certificates = [];
pin = '';
certResolve = null;
if (pinReject) {
const reject = pinReject;
pinResolve = null;
pinReject = null;
reject(new Error('login cancelled'));
}
}
function setMode(mode: PinMode) {
pinMode = mode;
resetInteractive();
}
function showCertificates(list: CertificateSummary[]): Promise<string> {
return new Promise((resolve, reject) => {
if (!list.length) {
reject(new Error('Uygun imza sertifikası yok'));
return;
}
certResolve = resolve;
certificates = list;
certPanel = true;
setLog(`CERTIFICATE_LIST — ${list.length} sertifika; birini seçin`);
});
}
function requestPin(reason?: string): Promise<string> {
return new Promise((resolve, reject) => {
pinResolve = resolve;
pinReject = reject;
pinPanel = true;
pin = '';
if (reason && reason !== 'pin_required') {
pinTitle = 'PIN yeniden';
pinHint = `PIN reddedildi (${reason}). Challenge tüketilmez.`;
setLog(`PIN_RETRY: ${reason}`, 'err');
} else {
pinTitle = 'Token PIN';
pinHint = 'PIN yalnızca Agent WebSocket PIN_SUBMIT ile gider.';
}
});
}
function selectCertificate(id: string) {
if (certResolve) {
const resolve = certResolve;
certResolve = null;
certPanel = false;
resolve(id);
}
}
function submitPin() {
if (!pin) {
setLog('PIN boş', 'err');
return;
}
if (pinResolve) {
const resolve = pinResolve;
pinResolve = null;
pinReject = null;
pinPanel = false;
resolve(pin);
pin = '';
}
}
async function startPending() {
busy = true;
loginResult = null;
try {
if (!pendingUrl.trim()) {
correlationId = crypto.randomUUID();
setLog('Yerel correlation_id üretildi.\n' + correlationId, 'ok');
return;
}
const res = await fetch(pendingUrl.trim(), {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
credentials: 'include',
body: '{}'
});
const data = (await res.json().catch(() => ({}))) as {
correlation_id?: string;
correlationId?: string;
message?: string;
};
if (!res.ok) throw new Error(data.message || `HTTP ${res.status}`);
const id = data.correlation_id || data.correlationId;
if (!id) throw new Error('Pending yanıtında correlation_id yok');
correlationId = id;
setLog('Pending OK\n' + JSON.stringify(data, null, 2), 'ok');
} catch (e) {
setLog(e instanceof Error ? e.message : String(e), 'err');
} finally {
busy = false;
}
}
async function detectAgent() {
busy = true;
try {
const result = await discoverAgent();
setLog(
'Agent bulundu\n' +
`pingUrl: ${result.pingUrl}\n` +
`agentBaseUrl: ${result.agentBaseUrl}\n` +
`wsLoginUrl: ${result.wsLoginUrl}`,
'ok'
);
} catch (e) {
setLog(e instanceof Error ? e.message : String(e), 'err');
} finally {
busy = false;
}
}
async function login() {
busy = true;
loginResult = null;
lastEvent = '';
resetInteractive();
try {
if (!publishableKey.trim()) throw new Error('publishableKey gerekli');
const corr = correlationId.trim() || crypto.randomUUID();
if (!correlationId.trim()) correlationId = corr;
const client = new KSign({
publishableKey: publishableKey.trim(),
backendUrl: backendUrl.replace(/\/+$/, '')
});
const loginOptions: Parameters<KSign['login']>[0] = {
correlationId: corr,
pinMode,
onEvent: (event: LoginSessionEvent) => {
lastEvent = event.type;
console.log('[ksign]', event.type, event);
if (event.type === 'READY' && pinMode === 'agent') {
agentPanel = true;
setLog('READY — Agent native UI bekleniyor');
} else if (
event.type === 'PIN_ACCEPTED' ||
event.type === 'SIGNING' ||
event.type === 'QUEUE_WAIT'
) {
resetInteractive();
} else if (event.type === 'PIN_RETRY' && pinMode === 'agent') {
agentPanel = true;
agentHint =
'Yanlış PIN — Agent penceresinde tekrar deneyin (challenge tüketilmez).';
setLog(`PIN_RETRY (Mode B): ${'reason' in event ? event.reason : 'wrong_pin'}`, 'err');
}
}
};
if (pinMode === 'browser') {
loginOptions.onSelectCertificate = showCertificates;
loginOptions.onRequestPin = () => requestPin();
loginOptions.onPinRetry = (reason) => requestPin(reason);
}
const result = await client.login(loginOptions);
loginResult = result;
resetInteractive();
setLog(
`Login tamamlandı (${pinMode === 'agent' ? 'Mode B' : 'Mode A'}, JWT yok)\n` +
JSON.stringify(result, null, 2) +
'\n\nOturum: müşteri backend Login Result accepted sonrası.',
'ok'
);
} catch (e) {
resetInteractive();
const err = e as { code?: string; message?: string; challengeId?: string; backendCode?: number };
const body =
err && err.code
? {
code: err.code,
message: err.message,
challengeId: err.challengeId,
backendCode: err.backendCode
}
: e instanceof Error
? e.message
: String(e);
setLog('Login başarısız\n' + JSON.stringify(body, null, 2), 'err');
} finally {
busy = false;
}
}
</script>
<section class="esign">
<div class="modes" role="group" aria-label="PIN mode">
<button
type="button"
class:active={pinMode === 'browser'}
aria-pressed={pinMode === 'browser'}
disabled={busy}
onclick={() => setMode('browser')}
>
<strong>Mode A — Browser</strong>
<span>CERTIFICATE_LIST → PIN_SUBMIT</span>
</button>
<button
type="button"
class:active={pinMode === 'agent'}
aria-pressed={pinMode === 'agent'}
disabled={busy}
onclick={() => setMode('agent')}
>
<strong>Mode B — Agent</strong>
<span>Native cert + PIN</span>
</button>
</div>
<label>
correlation_id
<input bind:value={correlationId} placeholder="Pending’den veya UUID" autocomplete="off" />
</label>
{#if certPanel}
<div class="panel">
<p class="panel-title">Sertifika seçin</p>
{#each certificates as cert (cert.certificateId)}
<button type="button" class="cert" disabled={busy} onclick={() => selectCertificate(cert.certificateId)}>
<span class="subj">{cert.subject || cert.certificateId}</span>
<code>{cert.certificateId}</code>
</button>
{/each}
</div>
{/if}
{#if pinPanel}
<div class="panel">
<p class="panel-title">{pinTitle}</p>
<p class="hint">{pinHint}</p>
<input bind:value={pin} type="password" autocomplete="off" placeholder="USB token PIN" />
<button type="button" disabled={busy} onclick={submitPin}>PIN gönder</button>
</div>
{/if}
{#if agentPanel}
<div class="panel agent">
<p class="panel-title">Agent penceresi</p>
<p class="hint">{agentHint}</p>
</div>
{/if}
<div class="actions">
<button type="button" disabled={busy} onclick={startPending}>Pending Login</button>
<button type="button" disabled={busy} onclick={detectAgent}>Detect Agent</button>
<button type="button" disabled={busy} onclick={login}>Login with e-Sign</button>
</div>
{#if lastEvent}
<p class="event">Son WS olayı: <code>{lastEvent}</code></p>
{/if}
{#if loginResult}
<p class="ok">
{pinMode === 'agent' ? 'Mode B' : 'Mode A'} · status: <code>{loginResult.status}</code> · challenge:
<code>{loginResult.challengeId}</code>
</p>
{/if}
<pre class:ok={logKind === 'ok'} class:err={logKind === 'err'}>{logText}</pre>
</section>
<style>
.esign {
display: grid;
gap: 0.85rem;
max-width: 32rem;
}
.modes {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.modes button {
text-align: left;
padding: 0.55rem 0.65rem;
border-radius: 8px;
border: 1px solid #8884;
background: transparent;
cursor: pointer;
display: grid;
gap: 0.15rem;
}
.modes button.active {
border-color: #3d8bfd;
background: color-mix(in srgb, #3d8bfd 12%, transparent);
}
.modes button strong {
font-size: 0.92rem;
}
.modes button span {
font-size: 0.78rem;
color: #666;
font-weight: 400;
}
label {
display: grid;
gap: 0.35rem;
font-weight: 600;
font-size: 0.9rem;
}
input {
font: inherit;
padding: 0.5rem 0.65rem;
}
.panel {
padding: 0.75rem;
border: 1px solid #8884;
border-radius: 8px;
display: grid;
gap: 0.5rem;
}
.panel-title {
margin: 0;
font-weight: 600;
}
.hint {
margin: 0;
font-size: 0.85rem;
color: #666;
}
.cert {
text-align: left;
padding: 0.5rem 0.65rem;
border: 1px solid #8884;
border-radius: 6px;
background: transparent;
cursor: pointer;
display: grid;
gap: 0.2rem;
}
.cert .subj {
font-weight: 600;
font-size: 0.9rem;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
button {
font: inherit;
cursor: pointer;
padding: 0.5rem 0.85rem;
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
pre {
margin: 0;
padding: 0.85rem;
border-radius: 8px;
background: #111;
color: #eee;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.85rem;
}
pre.ok {
color: #6dcfb0;
}
pre.err {
color: #f07178;
}
.event,
.ok {
margin: 0;
font-size: 0.9rem;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.88em;
}
</style>
+page.svelte ham dosya
<script lang="ts">
/**
* SvelteKit sayfa örneği — müşteri uygulamasında:
*
* src/routes/login/+page.svelte
*
* RESMI_AKIS_V2: Pending Login → challenge → Agent WS (Mode A veya B) → Login Result
*/
import { env } from '$env/dynamic/public';
import ESignLogin from '$lib/ksign/ESignLogin.svelte';
const publishableKey = env.PUBLIC_KSIGN_PUBLISHABLE_KEY ?? '';
const backendUrl = env.PUBLIC_KSIGN_API_URL ?? 'https://api.ksign.tr';
const pendingUrl = '/api/esign/pending';
</script>
<svelte:head>
<title>e-İmza ile giriş</title>
</svelte:head>
<main>
<h1>e-İmza ile giriş</h1>
<p>
<strong>Mode A:</strong> tarayıcıda sertifika listesi + PIN (Agent WS).
<strong>Mode B:</strong> Agent native UI — tarayıcı cert/PIN görmez.
KSign JWT tarayıcıya gelmez; oturum Login Result <code>accepted</code> sonrası sunucunuzda oluşur.
</p>
{#if !publishableKey}
<p class="warn">PUBLIC_KSIGN_PUBLISHABLE_KEY tanımlı değil.</p>
{:else}
<ESignLogin {publishableKey} {backendUrl} {pendingUrl} />
{/if}
</main>
<style>
main {
max-width: 36rem;
margin: 2rem auto;
padding: 0 1rem;
font-family: system-ui, sans-serif;
}
.warn {
color: #b45309;
}
</style>
README.md ham dosya
# Svelte / SvelteKit örnekleri (v2.3 — RESMI_AKIS_V2)
| Dosya | Yerleşim (müşteri projesi) |
|-------|----------------------------|
| `ESignLogin.svelte` | `src/lib/ksign/ESignLogin.svelte` |
| `+page.svelte` | `src/routes/login/+page.svelte` |
| `server/pending/+server.ts` | `src/routes/api/esign/pending/+server.ts` |
| `server/login-result/+server.ts` | `src/routes/api/ksign/login-result/+server.ts` |
```bash
npm install @ksign/browser
```
## Mode A / Mode B (§9)
| | Mode A (`pinMode: 'browser'`) | Mode B (`pinMode: 'agent'`) |
|---|-------------------------------|-----------------------------|
| Sertifika seçimi | Browser UI — `CERTIFICATE_LIST` | Agent native UI |
| PIN | Browser → `PIN_SUBMIT` (WS) | Agent native UI |
| SDK callback | `onSelectCertificate`, `onRequestPin` | yalnız `onEvent` |
Her iki mod aynı backend zincirini kullanır: `agent-verify` → Login Result → `accepted` → `LOGIN_SUCCESS`.
```typescript
// Mode B
await client.login({
correlationId,
pinMode: 'agent',
onEvent: (ev) => console.log(ev.type),
});
```
Env (örnek):
```env
PUBLIC_KSIGN_PUBLISHABLE_KEY=ksign_pk_...
PUBLIC_KSIGN_API_URL=https://api.ksign.tr
KSIGN_CALLBACK_SECRET=... # yalnız sunucu; X-KSign-Secret-Key
```
Secret Key / callback secret asla `PUBLIC_*` altına konmaz.
Pending Login + Login Result (müşteri sunucusu).
pending/+server.ts ham dosya
/**
* SvelteKit — Pending Login (RESMI_AKIS_V2 §1–2)
*
* Yerleşim: src/routes/api/esign/pending/+server.ts
*
* Mode A ve Mode B aynı correlation_id ile challenge oluşturur.
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
export const POST: RequestHandler = async () => {
const correlationId = crypto.randomUUID();
const expiresAt = Math.floor(Date.now() / 1000) + 300;
// TODO: Redis / DB — pending kaydı (session ipucu ile bağlayın)
return json({
success: true,
correlation_id: correlationId,
expires_at: expiresAt
});
};
login-result/+server.ts ham dosya
/**
* SvelteKit — Login Result callback (RESMI_AKIS_V2 §17–22)
*
* Yerleşim: src/routes/api/ksign/login-result/+server.ts
* Auth: X-KSign-Secret-Key → { success: true, status: "accepted", correlation_id }
*
* access_token yalnızca burada — Browser/Agent/SDK görmez.
*/
import { createHash, timingSafeEqual } from 'node:crypto';
import { env } from '$env/dynamic/private';
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
type LoginResultBody = {
correlation_id?: string;
challenge_id?: string;
access_token?: string;
expires_at?: number;
enrollment_status?: string;
identity?: {
tckn?: string;
first_name?: string;
last_name?: string;
email?: string;
};
certificate?: {
certificate_uuid?: string;
thumbprint_sha256?: string;
serial_number?: string;
};
verification?: { valid?: boolean; certificate_sha256?: string };
};
function secretMatches(expected: string, got: string | null): boolean {
if (!expected || !got) return false;
const a = createHash('sha256').update(expected).digest();
const b = createHash('sha256').update(got).digest();
return a.length === b.length && timingSafeEqual(a, b);
}
export const POST: RequestHandler = async ({ request }) => {
const expected = env.KSIGN_CALLBACK_SECRET ?? '';
if (!expected) {
throw error(500, 'callback secret not configured');
}
const got = request.headers.get('X-KSign-Secret-Key');
if (!secretMatches(expected, got)) {
throw error(401, 'unauthorized');
}
let body: LoginResultBody;
try {
body = (await request.json()) as LoginResultBody;
} catch {
throw error(400, 'invalid json');
}
const correlationId = String(body.correlation_id ?? '');
const challengeId = String(body.challenge_id ?? '');
const accessToken = String(body.access_token ?? '');
if (!correlationId || !challengeId || !accessToken) {
throw error(400, 'missing required fields');
}
if (body.verification?.valid !== true) {
throw error(400, 'verification not valid');
}
// TODO: pending kaydını correlation_id ile doğrula
// TODO: KSign JWT’yi JWKS ile doğrula; uygulama oturumu oluştur (cookie / Redis)
return json({
success: true,
status: 'accepted',
correlation_id: correlationId
});
};
RESMI_AKIS_V2 §2–22 — Pending Login + Login Result (Mode A/B aynı backend). JWT yalnız burada.
Araç
Pending store + Login Result + session cookie (Go/Node ile aynı zincir).
README.md ham dosya
# PHP müşteri backend örneği
RESMI_AKIS_V2 sunucu tarafı — Go/Node ile aynı doğrulama zinciri.
## Dosyalar
| Dosya | Route |
|-------|--------|
| `pending-login.php` | `POST /api/esign/pending` |
| `login-result.php` | `POST /api/ksign/login-result` |
| `session.php` | `GET\|DELETE /api/session` |
| `store.php` | Pending + session (temp dosya; production Redis/DB) |
| `users.php` | TCKN / e-posta eşlemesi + JWT claim cross-check |
## Akış
```
POST pending-login.php
→ correlation_id kaydı (5 dk TTL)
POST login-result.php ← KSign api
├─ X-KSign-Secret-Key
├─ correlation_id → pending
├─ verification.valid
├─ access_token claims ↔ body
├─ identity → demo kullanıcı
└─ app_session cookie + accepted
GET session.php → oturum durumu
```
## Ortam
```bash
export KSIGN_CALLBACK_SECRET='ksign_sk_test_...'
```
Demo kullanıcı: `TCKN=12345678901` veya `demo@smsc.tr`.
Web sunucusuna route map edin (ör. Nginx/`php -S` ile rewrite). `store.php` ve `users.php` doğrudan serve edilmemelidir.
pending-login.php ham dosya
<?php
/**
* KSign müşteri backend — Pending Login (PHP)
*
* RESMI_AKIS_V2 §1–2 — Mode A/B aynı correlation_id ile challenge oluşturur.
* Route: POST /api/esign/pending
*
* Depo: store.php (temp dosya). Production: Redis / DB.
*/
declare(strict_types=1);
require_once __DIR__ . '/store.php';
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'method not allowed'], JSON_UNESCAPED_UNICODE);
exit;
}
$pending = ksign_create_pending();
http_response_code(200);
echo json_encode([
'success' => true,
'correlation_id' => $pending['correlation_id'],
'expires_at' => $pending['expires_at'],
], JSON_UNESCAPED_UNICODE);
login-result.php ham dosya
<?php
/**
* KSign müşteri backend — Login Result callback (PHP)
*
* RESMI_AKIS_V2 §17–22
* Route: POST /api/ksign/login-result
* Auth: X-KSign-Secret-Key
*
* Akış:
* 1) Secret Key
* 2) zorunlu alanlar + verification.valid
* 3) correlation_id → pending (yok 404 / süresi dolmuş 410)
* 4) access_token claims ↔ body cross-check
* 5) identity → kullanıcı eşlemesi
* 6) app_session cookie + { status: "accepted" }
*/
declare(strict_types=1);
require_once __DIR__ . '/store.php';
require_once __DIR__ . '/users.php';
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'method not allowed'], JSON_UNESCAPED_UNICODE);
exit;
}
$expectedSecret = getenv('KSIGN_CALLBACK_SECRET') ?: '';
if ($expectedSecret === '') {
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'callback secret not configured'], JSON_UNESCAPED_UNICODE);
exit;
}
$gotSecret = $_SERVER['HTTP_X_KSIGN_SECRET_KEY'] ?? '';
if ($gotSecret === '' || !hash_equals($expectedSecret, $gotSecret)) {
http_response_code(401);
echo json_encode(['success' => false, 'message' => 'unauthorized'], JSON_UNESCAPED_UNICODE);
exit;
}
$raw = file_get_contents('php://input');
$payload = json_decode($raw ?: 'null', true);
if (!is_array($payload)) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'invalid json'], JSON_UNESCAPED_UNICODE);
exit;
}
$correlationId = isset($payload['correlation_id']) ? (string) $payload['correlation_id'] : '';
$challengeId = isset($payload['challenge_id']) ? (string) $payload['challenge_id'] : '';
$accessToken = isset($payload['access_token']) ? (string) $payload['access_token'] : '';
$verification = isset($payload['verification']) && is_array($payload['verification'])
? $payload['verification']
: [];
$identity = isset($payload['identity']) && is_array($payload['identity'])
? $payload['identity']
: [];
$certificate = isset($payload['certificate']) && is_array($payload['certificate'])
? $payload['certificate']
: [];
if ($correlationId === '' || $challengeId === '' || $accessToken === '') {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'missing required fields'], JSON_UNESCAPED_UNICODE);
exit;
}
if (($verification['valid'] ?? false) !== true) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'verification not valid'], JSON_UNESCAPED_UNICODE);
exit;
}
$taken = ksign_take_pending($correlationId);
if ($taken['status'] === 'not_found') {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'unknown correlation_id'], JSON_UNESCAPED_UNICODE);
exit;
}
if ($taken['status'] === 'expired') {
http_response_code(410);
echo json_encode(['success' => false, 'message' => 'pending login expired'], JSON_UNESCAPED_UNICODE);
exit;
}
if (!ksign_cross_check_access_token($accessToken, $correlationId, $challengeId)) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'access_token claims mismatch'], JSON_UNESCAPED_UNICODE);
exit;
}
$user = ksign_resolve_user(
(string) ($identity['tckn'] ?? ''),
(string) ($identity['email'] ?? ''),
(string) ($certificate['thumbprint_sha256'] ?? '')
);
if ($user === null) {
http_response_code(403);
echo json_encode([
'success' => false,
'message' => 'user not found in customer system',
], JSON_UNESCAPED_UNICODE);
exit;
}
$session = ksign_create_session($user['id'], $correlationId, $challengeId);
ksign_set_session_cookie($session['id'], (int) $session['expires_at']);
http_response_code(200);
echo json_encode([
'success' => true,
'status' => 'accepted',
'correlation_id' => $correlationId,
], JSON_UNESCAPED_UNICODE);
session.php ham dosya
<?php
/**
* Demo uygulama oturumu — RESMI_AKIS_V2 §22 (müşteri oturumu KSign dışı)
*
* GET /api/session
* DELETE /api/session
*/
declare(strict_types=1);
require_once __DIR__ . '/store.php';
header('Content-Type: application/json; charset=utf-8');
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$sid = isset($_COOKIE[KSIGN_SESSION_COOKIE]) ? (string) $_COOKIE[KSIGN_SESSION_COOKIE] : '';
if ($method === 'GET') {
$session = ksign_get_session($sid);
if ($session === null) {
echo json_encode(['authenticated' => false], JSON_UNESCAPED_UNICODE);
exit;
}
echo json_encode([
'authenticated' => true,
'user_id' => $session['user_id'],
'correlation_id' => $session['correlation_id'],
'challenge_id' => $session['challenge_id'],
'session_expires' => (int) $session['expires_at'],
], JSON_UNESCAPED_UNICODE);
exit;
}
if ($method === 'DELETE') {
ksign_delete_session($sid);
ksign_set_session_cookie('', 0, true);
echo json_encode(['success' => true], JSON_UNESCAPED_UNICODE);
exit;
}
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'method not allowed'], JSON_UNESCAPED_UNICODE);
store.php ham dosya
<?php
/**
* In-memory-style file store for PHP demo (RESMI_AKIS_V2).
* Production: Redis / MariaDB.
*/
declare(strict_types=1);
const KSIGN_PENDING_TTL = 300;
const KSIGN_SESSION_TTL = 86400;
const KSIGN_SESSION_COOKIE = 'app_session';
function ksign_store_path(string $kind): string
{
$dir = sys_get_temp_dir() . '/ksign-customer-example';
if (!is_dir($dir)) {
mkdir($dir, 0700, true);
}
return $dir . '/' . $kind . '.json';
}
/**
* @return array<string, mixed>
*/
function ksign_load(string $kind): array
{
$path = ksign_store_path($kind);
if (!is_file($path)) {
return [];
}
$raw = file_get_contents($path);
$data = json_decode($raw ?: '[]', true);
return is_array($data) ? $data : [];
}
/**
* @param array<string, mixed> $data
*/
function ksign_save(string $kind, array $data): void
{
$path = ksign_store_path($kind);
$fp = fopen($path, 'c+');
if ($fp === false) {
throw new RuntimeException('store open failed');
}
try {
if (!flock($fp, LOCK_EX)) {
throw new RuntimeException('store lock failed');
}
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE));
fflush($fp);
flock($fp, LOCK_UN);
} finally {
fclose($fp);
}
}
function ksign_new_uuid(): string
{
return sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
random_int(0, 0xffff),
random_int(0, 0xffff),
random_int(0, 0xffff),
random_int(0, 0x0fff) | 0x4000,
random_int(0, 0x3fff) | 0x8000,
random_int(0, 0xffff),
random_int(0, 0xffff),
random_int(0, 0xffff)
);
}
/**
* @return array{correlation_id: string, expires_at: int}
*/
function ksign_create_pending(): array
{
$pending = ksign_load('pending');
$correlationId = ksign_new_uuid();
$expiresAt = time() + KSIGN_PENDING_TTL;
$pending[$correlationId] = [
'expires_at' => $expiresAt,
'created_at' => time(),
];
ksign_save('pending', $pending);
return [
'correlation_id' => $correlationId,
'expires_at' => $expiresAt,
];
}
/**
* Take-and-remove pending. Returns status: ok | not_found | expired
*
* @return array{status: string}
*/
function ksign_take_pending(string $correlationId): array
{
$pending = ksign_load('pending');
if (!isset($pending[$correlationId]) || !is_array($pending[$correlationId])) {
return ['status' => 'not_found'];
}
$entry = $pending[$correlationId];
unset($pending[$correlationId]);
ksign_save('pending', $pending);
$expiresAt = (int) ($entry['expires_at'] ?? 0);
if ($expiresAt < time()) {
return ['status' => 'expired'];
}
return ['status' => 'ok'];
}
/**
* @return array{id: string, user_id: string, correlation_id: string, challenge_id: string, expires_at: int}
*/
function ksign_create_session(string $userId, string $correlationId, string $challengeId): array
{
$sessions = ksign_load('sessions');
$id = bin2hex(random_bytes(32));
$expiresAt = time() + KSIGN_SESSION_TTL;
$session = [
'id' => $id,
'user_id' => $userId,
'correlation_id' => $correlationId,
'challenge_id' => $challengeId,
'expires_at' => $expiresAt,
'created_at' => time(),
];
$sessions[$id] = $session;
ksign_save('sessions', $sessions);
return $session;
}
/**
* @return array{id: string, user_id: string, correlation_id: string, challenge_id: string, expires_at: int}|null
*/
function ksign_get_session(string $id): ?array
{
if ($id === '') {
return null;
}
$sessions = ksign_load('sessions');
if (!isset($sessions[$id]) || !is_array($sessions[$id])) {
return null;
}
$session = $sessions[$id];
if ((int) ($session['expires_at'] ?? 0) < time()) {
unset($sessions[$id]);
ksign_save('sessions', $sessions);
return null;
}
return $session;
}
function ksign_delete_session(string $id): void
{
if ($id === '') {
return;
}
$sessions = ksign_load('sessions');
unset($sessions[$id]);
ksign_save('sessions', $sessions);
}
function ksign_set_session_cookie(string $id, int $expiresAt, bool $clear = false): void
{
$opts = [
'expires' => $clear ? time() - 3600 : $expiresAt,
'path' => '/',
'httponly' => true,
'samesite' => 'Lax',
'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'),
];
setcookie(KSIGN_SESSION_COOKIE, $clear ? '' : $id, $opts);
}
users.php ham dosya
<?php
/**
* Demo kullanıcı eşlemesi — RESMI_AKIS_V2 §18
* Production: DB / LDAP / IdP.
*/
declare(strict_types=1);
/**
* @return list<array{id: string, tckn: string, email: string, name: string}>
*/
function ksign_demo_users(): array
{
return [
[
'id' => 'usr_demo_001',
'tckn' => '12345678901',
'email' => 'demo@smsc.tr',
'name' => 'Demo Kullanıcı',
],
];
}
/**
* TCKN önce, sonra e-posta (case-insensitive).
*
* @return array{id: string, tckn: string, email: string, name: string}|null
*/
function ksign_resolve_user(string $tckn, string $email, string $thumbprint): ?array
{
$tckn = trim($tckn);
$email = strtolower(trim($email));
unset($thumbprint); // opsiyonel: thumbprint → user_id
foreach (ksign_demo_users() as $user) {
if ($tckn !== '' && $user['tckn'] === $tckn) {
return $user;
}
}
foreach (ksign_demo_users() as $user) {
if ($email !== '' && strtolower($user['email']) === $email) {
return $user;
}
}
return null;
}
/**
* JWT payload cross-check (Secret Key geçtikten sonra).
* Tam imza doğrulaması platform sözleşmesine göre ayrı yapılır.
*/
function ksign_cross_check_access_token(string $accessToken, string $correlationId, string $challengeId): bool
{
$parts = explode('.', $accessToken);
if (count($parts) !== 3 || $parts[0] === '' || $parts[1] === '') {
return false;
}
$payload = $parts[1];
$pad = strlen($payload) % 4;
if ($pad > 0) {
$payload .= str_repeat('=', 4 - $pad);
}
$raw = base64_decode(strtr($payload, '-_', '+/'), true);
if ($raw === false) {
return false;
}
$claims = json_decode($raw, true);
if (!is_array($claims)) {
return false;
}
if (($claims['auth_method'] ?? '') !== 'esign') {
return false;
}
$jc = isset($claims['correlation_id']) ? (string) $claims['correlation_id'] : '';
if ($jc !== '' && $jc !== $correlationId) {
return false;
}
$jid = isset($claims['challenge_id']) ? (string) $claims['challenge_id'] : '';
if ($jid !== '' && $jid !== $challengeId) {
return false;
}
return true;
}
Çalıştırılabilir demo: go run . — pending, Login Result, session cookie.
README.md ham dosya
# Go müşteri backend örneği
RESMI_AKIS_V2 sunucu tarafı — çalıştırılabilir demo (`go run .`).
## Akış
```
POST /api/esign/pending → correlation_id (in-memory store, 5 dk TTL)
POST /api/ksign/login-result ← KSign api
├─ X-KSign-Secret-Key
├─ correlation_id → pending kaydı
├─ verification.valid
├─ access_token claims ↔ body cross-check
├─ identity → demo kullanıcı eşlemesi (TCKN / e-posta)
└─ app_session cookie + { status: "accepted" }
GET /api/session → oturum durumu (demo)
```
## Çalıştırma
```bash
export KSIGN_CALLBACK_SECRET='ksign_sk_test_...' # portal Secret Key
go run .
```
Demo kullanıcı: `TCKN=12345678901` veya `email=demo@smsc.tr` — `users.go` içinde.
Production: `store.go` → Redis/DB; `ResolveUser` → gerçek repository; JWT imza doğrulaması platform sözleşmesine göre.
main.go ham dosya
// KSign müşteri backend örneği (Go) — çalıştırılabilir demo
//
// RESMI_AKIS_V2 tam zincir (sunucu tarafı):
// POST /api/esign/pending → correlation_id kaydı
// POST /api/ksign/login-result → secret + pending + verify + user + session → accepted
// GET /api/session → uygulama oturumu (demo cookie)
//
// Env:
// KSIGN_CALLBACK_SECRET — Portal Secret Key (X-KSign-Secret-Key)
// PORT — default 3100
//
// go run .
package main
import (
"fmt"
"log"
"net/http"
"os"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "3100"
}
store := NewStore()
mux := http.NewServeMux()
mux.HandleFunc("/api/esign/pending", pendingLoginHandler(store))
mux.HandleFunc("/api/ksign/login-result", loginResultHandler(store))
mux.HandleFunc("/api/session", sessionHandler(store))
addr := ":" + port
log.Printf("KSign customer example listening on %s", addr)
log.Println(" POST /api/esign/pending")
log.Println(" POST /api/ksign/login-result")
log.Println(" GET /api/session")
if err := http.ListenAndServe(addr, mux); err != nil {
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
os.Exit(1)
}
}
pending_login.go ham dosya
// RESMI_AKIS_V2 §1–2 — Pending Login (Mode A/B aynı correlation_id)
// Route: POST /api/esign/pending
package main
import (
"encoding/json"
"net/http"
"time"
)
const pendingTTL = 5 * time.Minute
type pendingResponse struct {
Success bool `json:"success"`
CorrelationID string `json:"correlation_id"`
ExpiresAt int64 `json:"expires_at"`
}
func pendingLoginHandler(store *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{
"success": false, "message": "method not allowed",
})
return
}
entry := store.CreatePending(pendingTTL)
writeJSON(w, http.StatusOK, pendingResponse{
Success: true,
CorrelationID: entry.CorrelationID,
ExpiresAt: entry.ExpiresAt.Unix(),
})
}
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
login_result.go ham dosya
// RESMI_AKIS_V2 §17–22 — Login Result callback
// Route: POST /api/ksign/login-result
// Auth: X-KSign-Secret-Key
package main
import (
"crypto/sha256"
"crypto/subtle"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"time"
)
const (
maxLoginResultBody = 1 << 20
sessionCookieName = "app_session"
sessionTTL = 24 * time.Hour
)
type loginResultRequest struct {
CorrelationID string `json:"correlation_id"`
ChallengeID string `json:"challenge_id"`
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresAt int64 `json:"expires_at"`
EnrollmentStatus string `json:"enrollment_status"`
Identity struct {
TCKN string `json:"tckn"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
} `json:"identity"`
Certificate struct {
CertificateUUID string `json:"certificate_uuid"`
ThumbprintSHA256 string `json:"thumbprint_sha256"`
SerialNumber string `json:"serial_number"`
} `json:"certificate"`
Verification struct {
Valid bool `json:"valid"`
CertificateSHA256 string `json:"certificate_sha256"`
} `json:"verification"`
}
type acceptedResponse struct {
Success bool `json:"success"`
Status string `json:"status"`
CorrelationID string `json:"correlation_id"`
}
func loginResultHandler(store *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{
"success": false, "message": "method not allowed",
})
return
}
expected := os.Getenv("KSIGN_CALLBACK_SECRET")
if expected == "" {
writeJSON(w, http.StatusInternalServerError, map[string]any{
"success": false, "message": "callback secret not configured",
})
return
}
got := r.Header.Get("X-KSign-Secret-Key")
if !secureEqual(expected, got) {
writeJSON(w, http.StatusUnauthorized, map[string]any{
"success": false, "message": "unauthorized",
})
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, maxLoginResultBody))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{
"success": false, "message": "read failed",
})
return
}
var req loginResultRequest
if err := json.Unmarshal(body, &req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{
"success": false, "message": "invalid json",
})
return
}
if req.CorrelationID == "" || req.ChallengeID == "" || req.AccessToken == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{
"success": false, "message": "missing required fields",
})
return
}
if !req.Verification.Valid {
writeJSON(w, http.StatusBadRequest, map[string]any{
"success": false, "message": "verification not valid",
})
return
}
if _, err := store.TakePending(req.CorrelationID); err != nil {
status := http.StatusNotFound
msg := "unknown correlation_id"
if errors.Is(err, ErrPendingExpired) {
status = http.StatusGone
msg = "pending login expired"
}
writeJSON(w, status, map[string]any{"success": false, "message": msg})
return
}
if err := crossCheckAccessToken(req.AccessToken, req.CorrelationID, req.ChallengeID); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{
"success": false, "message": "access_token claims mismatch",
})
return
}
user, err := ResolveUser(
req.Identity.TCKN,
req.Identity.Email,
req.Certificate.ThumbprintSHA256,
)
if err != nil {
writeJSON(w, http.StatusForbidden, map[string]any{
"success": false,
"message": "user not found in customer system",
})
return
}
session := store.CreateSession(user.ID, req.CorrelationID, req.ChallengeID, sessionTTL)
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: session.ID,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: r.TLS != nil,
Expires: session.ExpiresAt,
})
writeJSON(w, http.StatusOK, acceptedResponse{
Success: true,
Status: "accepted",
CorrelationID: req.CorrelationID,
})
}
}
func secureEqual(a, b string) bool {
ha := sha256.Sum256([]byte(a))
hb := sha256.Sum256([]byte(b))
return subtle.ConstantTimeCompare(ha[:], hb[:]) == 1
}
store.go ham dosya
package main
import (
"crypto/rand"
"encoding/hex"
"errors"
"sync"
"time"
)
var (
ErrPendingNotFound = errors.New("pending login not found")
ErrPendingExpired = errors.New("pending login expired")
ErrSessionNotFound = errors.New("session not found")
)
type PendingLogin struct {
CorrelationID string
ExpiresAt time.Time
CreatedAt time.Time
}
type AppSession struct {
ID string
UserID string
CorrelationID string
ChallengeID string
CreatedAt time.Time
ExpiresAt time.Time
}
// Store is an in-memory demo store. Production: Redis / MariaDB.
type Store struct {
mu sync.Mutex
pending map[string]PendingLogin
sessions map[string]AppSession
}
func NewStore() *Store {
return &Store{
pending: make(map[string]PendingLogin),
sessions: make(map[string]AppSession),
}
}
func (s *Store) CreatePending(ttl time.Duration) PendingLogin {
s.mu.Lock()
defer s.mu.Unlock()
var b [16]byte
_, _ = rand.Read(b[:])
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
correlationID := hex.EncodeToString(b[0:4]) + "-" +
hex.EncodeToString(b[4:6]) + "-" +
hex.EncodeToString(b[6:8]) + "-" +
hex.EncodeToString(b[8:10]) + "-" +
hex.EncodeToString(b[10:16])
now := time.Now().UTC()
entry := PendingLogin{
CorrelationID: correlationID,
CreatedAt: now,
ExpiresAt: now.Add(ttl),
}
s.pending[correlationID] = entry
return entry
}
// TakePending removes and returns a pending login if valid.
func (s *Store) TakePending(correlationID string) (PendingLogin, error) {
s.mu.Lock()
defer s.mu.Unlock()
entry, ok := s.pending[correlationID]
if !ok {
return PendingLogin{}, ErrPendingNotFound
}
delete(s.pending, correlationID)
if time.Now().UTC().After(entry.ExpiresAt) {
return PendingLogin{}, ErrPendingExpired
}
return entry, nil
}
func (s *Store) CreateSession(userID, correlationID, challengeID string, ttl time.Duration) AppSession {
s.mu.Lock()
defer s.mu.Unlock()
var token [32]byte
_, _ = rand.Read(token[:])
id := hex.EncodeToString(token[:])
now := time.Now().UTC()
session := AppSession{
ID: id,
UserID: userID,
CorrelationID: correlationID,
ChallengeID: challengeID,
CreatedAt: now,
ExpiresAt: now.Add(ttl),
}
s.sessions[id] = session
return session
}
func (s *Store) GetSession(id string) (AppSession, error) {
s.mu.Lock()
defer s.mu.Unlock()
session, ok := s.sessions[id]
if !ok {
return AppSession{}, ErrSessionNotFound
}
if time.Now().UTC().After(session.ExpiresAt) {
delete(s.sessions, id)
return AppSession{}, ErrSessionNotFound
}
return session, nil
}
func (s *Store) DeleteSession(id string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, id)
}
users.go ham dosya
package main
import (
"errors"
"strings"
)
var ErrUserNotFound = errors.New("user not found")
// User is a demo SMSC account row. Production: your DB / LDAP / IdP.
type User struct {
ID string
TCKN string
Email string
Name string
}
// demoUsers — replace with repository lookup.
var demoUsers = []User{
{ID: "usr_demo_001", TCKN: "12345678901", Email: "demo@smsc.tr", Name: "Demo Kullanıcı"},
}
// ResolveUser maps verified certificate identity to an application user (RESMI_AKIS_V2 §18).
// Policy: TCKN first, then email (case-insensitive). Extend with thumbprint map if needed.
func ResolveUser(tckn, email, thumbprint string) (User, error) {
tckn = strings.TrimSpace(tckn)
email = strings.TrimSpace(strings.ToLower(email))
_ = thumbprint // optional: customer_certificates.thumbprint_sha256 → user_id
if tckn != "" {
for _, u := range demoUsers {
if u.TCKN == tckn {
return u, nil
}
}
}
if email != "" {
for _, u := range demoUsers {
if strings.ToLower(u.Email) == email {
return u, nil
}
}
}
return User{}, ErrUserNotFound
}
Çalıştırılabilir: node server.mjs — pending, Login Result, session cookie.
README.md ham dosya
# Node.js müşteri backend örneği
RESMI_AKIS_V2 sunucu tarafı — çalıştırılabilir demo (`node server.mjs`).
## Akış
```
POST /api/esign/pending → correlation_id (in-memory, 5 dk TTL)
POST /api/ksign/login-result ← KSign api
├─ X-KSign-Secret-Key
├─ correlation_id → pending kaydı
├─ verification.valid
├─ access_token claims ↔ body cross-check
├─ identity → demo kullanıcı (TCKN / e-posta)
└─ app_session cookie + { status: "accepted" }
GET|DELETE /api/session → oturum durumu (demo)
```
## Çalıştırma
```bash
export KSIGN_CALLBACK_SECRET='ksign_sk_test_...'
node server.mjs
```
Demo kullanıcı: `TCKN=12345678901` veya `email=demo@smsc.tr`.
Production: Map → Redis/DB; `resolveUser` → gerçek repository.
server.mjs ham dosya
/**
* KSign müşteri backend — Pending Login + Login Result + Session (Node.js)
*
* RESMI_AKIS_V2 §1–22 — Mode A/B aynı backend zinciri; JWT yalnız Login Result'ta.
*
* Env:
* KSIGN_CALLBACK_SECRET — X-KSign-Secret-Key ile karşılaştırılır
* PORT — default 3100
*
* Endpoints:
* POST /api/esign/pending
* POST /api/ksign/login-result
* GET|DELETE /api/session
*
* Çalıştırma: node server.mjs
*/
import http from 'node:http';
import {
randomUUID,
randomBytes,
createHash,
timingSafeEqual,
} from 'node:crypto';
const PORT = Number(process.env.PORT || 3100);
const CALLBACK_SECRET = process.env.KSIGN_CALLBACK_SECRET || '';
const SESSION_COOKIE = 'app_session';
const PENDING_TTL_SEC = 300;
const SESSION_TTL_SEC = 24 * 60 * 60;
/** @type {Map<string, { expiresAt: number }>} */
const pending = new Map();
/** @type {Map<string, { userId: string, correlationId: string, challengeId: string, expiresAt: number }>} */
const sessions = new Map();
/** Demo SMSC users — production: DB / LDAP / IdP */
const demoUsers = [
{
id: 'usr_demo_001',
tckn: '12345678901',
email: 'demo@smsc.tr',
name: 'Demo Kullanıcı',
},
];
function json(res, status, body, extraHeaders = {}) {
const raw = JSON.stringify(body);
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(raw),
...extraHeaders,
});
res.end(raw);
}
function readBody(req, limit = 1 << 20) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
req.on('data', (c) => {
size += c.length;
if (size > limit) {
reject(new Error('body too large'));
req.destroy();
return;
}
chunks.push(c);
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
function secretOk(got) {
if (!CALLBACK_SECRET || !got) return false;
const a = createHash('sha256').update(CALLBACK_SECRET).digest();
const b = createHash('sha256').update(String(got)).digest();
return a.length === b.length && timingSafeEqual(a, b);
}
function nowSec() {
return Math.floor(Date.now() / 1000);
}
function takePending(correlationId) {
const entry = pending.get(correlationId);
if (!entry) return { ok: false, reason: 'not_found' };
pending.delete(correlationId);
if (entry.expiresAt < nowSec()) return { ok: false, reason: 'expired' };
return { ok: true, entry };
}
/**
* Decode JWT payload for cross-check only (after X-KSign-Secret-Key).
* Full HS256 verify requires platform agreement on shared signing material.
*/
function extractJwtClaims(token) {
const parts = String(token).split('.');
if (parts.length !== 3 || !parts[0] || !parts[1]) return null;
try {
const jsonStr = Buffer.from(parts[1], 'base64url').toString('utf8');
return JSON.parse(jsonStr);
} catch {
return null;
}
}
function crossCheckAccessToken(accessToken, correlationId, challengeId) {
const claims = extractJwtClaims(accessToken);
if (!claims) return 'invalid jwt';
if (claims.auth_method !== 'esign') return 'unexpected auth_method';
if (claims.correlation_id && claims.correlation_id !== correlationId) {
return 'jwt correlation_id mismatch';
}
if (claims.challenge_id && claims.challenge_id !== challengeId) {
return 'jwt challenge_id mismatch';
}
return null;
}
function resolveUser(tckn, email, _thumbprint) {
const t = String(tckn || '').trim();
const e = String(email || '').trim().toLowerCase();
if (t) {
const byTckn = demoUsers.find((u) => u.tckn === t);
if (byTckn) return byTckn;
}
if (e) {
const byEmail = demoUsers.find((u) => u.email.toLowerCase() === e);
if (byEmail) return byEmail;
}
return null;
}
function createSession(userId, correlationId, challengeId) {
const id = randomBytes(32).toString('hex');
const expiresAt = nowSec() + SESSION_TTL_SEC;
sessions.set(id, { userId, correlationId, challengeId, expiresAt });
return { id, expiresAt };
}
function getSession(id) {
const s = sessions.get(id);
if (!s) return null;
if (s.expiresAt < nowSec()) {
sessions.delete(id);
return null;
}
return s;
}
function parseCookies(header) {
/** @type {Record<string, string>} */
const out = {};
if (!header) return out;
for (const part of String(header).split(';')) {
const i = part.indexOf('=');
if (i < 0) continue;
const k = part.slice(0, i).trim();
const v = part.slice(i + 1).trim();
if (k) out[k] = decodeURIComponent(v);
}
return out;
}
function sessionCookieHeader(id, expiresAtSec, clear = false) {
if (clear) {
return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
}
const maxAge = Math.max(0, expiresAtSec - nowSec());
return `${SESSION_COOKIE}=${encodeURIComponent(id)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`;
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
if (req.method === 'POST' && url.pathname === '/api/esign/pending') {
const correlationId = randomUUID();
const expiresAt = nowSec() + PENDING_TTL_SEC;
pending.set(correlationId, { expiresAt });
return json(res, 200, {
success: true,
correlation_id: correlationId,
expires_at: expiresAt,
});
}
if (req.method === 'POST' && url.pathname === '/api/ksign/login-result') {
if (!CALLBACK_SECRET) {
return json(res, 500, { success: false, message: 'callback secret not configured' });
}
if (!secretOk(req.headers['x-ksign-secret-key'])) {
return json(res, 401, { success: false, message: 'unauthorized' });
}
let payload;
try {
payload = JSON.parse((await readBody(req)).toString('utf8'));
} catch {
return json(res, 400, { success: false, message: 'invalid json' });
}
const correlationId = String(payload.correlation_id || '');
const challengeId = String(payload.challenge_id || '');
const accessToken = String(payload.access_token || '');
const valid = payload.verification?.valid === true;
if (!correlationId || !challengeId || !accessToken) {
return json(res, 400, { success: false, message: 'missing required fields' });
}
if (!valid) {
return json(res, 400, { success: false, message: 'verification not valid' });
}
const taken = takePending(correlationId);
if (!taken.ok) {
if (taken.reason === 'expired') {
return json(res, 410, { success: false, message: 'pending login expired' });
}
return json(res, 404, { success: false, message: 'unknown correlation_id' });
}
const claimErr = crossCheckAccessToken(accessToken, correlationId, challengeId);
if (claimErr) {
return json(res, 400, { success: false, message: 'access_token claims mismatch' });
}
const identity = payload.identity || {};
const certificate = payload.certificate || {};
const user = resolveUser(
identity.tckn,
identity.email,
certificate.thumbprint_sha256
);
if (!user) {
return json(res, 403, {
success: false,
message: 'user not found in customer system',
});
}
const session = createSession(user.id, correlationId, challengeId);
return json(
res,
200,
{
success: true,
status: 'accepted',
correlation_id: correlationId,
},
{
'Set-Cookie': sessionCookieHeader(session.id, session.expiresAt),
}
);
}
if (url.pathname === '/api/session') {
const cookies = parseCookies(req.headers.cookie);
const sid = cookies[SESSION_COOKIE] || '';
if (req.method === 'GET') {
const session = sid ? getSession(sid) : null;
if (!session) {
return json(res, 200, { authenticated: false });
}
return json(res, 200, {
authenticated: true,
user_id: session.userId,
correlation_id: session.correlationId,
challenge_id: session.challengeId,
session_expires: session.expiresAt,
});
}
if (req.method === 'DELETE') {
if (sid) sessions.delete(sid);
return json(
res,
200,
{ success: true },
{ 'Set-Cookie': sessionCookieHeader('', 0, true) }
);
}
return json(res, 405, { success: false, message: 'method not allowed' });
}
json(res, 404, { success: false, message: 'not found' });
});
server.listen(PORT, () => {
console.log(`KSign customer example listening on :${PORT}`);
console.log(' POST /api/esign/pending');
console.log(' POST /api/ksign/login-result');
console.log(' GET|DELETE /api/session');
console.log(' Demo user: TCKN=12345678901 or demo@smsc.tr');
});