옴니ID 개발자 문서
가맹점 콘솔
문서 목록

가맹점 연동 예제

연동최종 수정 2026년 7월 31일

가이드의 전체 연동 흐름을 백엔드, 프론트엔드, 바이오 API 서버로 구현한 최소 동작 예제입니다.

목차

가맹점 연동 가이드의 전체 흐름을 백엔드, 프론트엔드, 가맹점 바이오 API 서버로 나눈 통합 골격입니다. 예제 내 인메모리 저장소는 로컬 흐름 확인용이며, Part A는 가맹점의 기존 로그인 세션·CSRF middleware를 먼저 연결해야 실행됩니다.

구성역할구현 환경
A. 가맹점 백엔드OAuth 토큰 발급 및 캐시, 세션 생성, 결과 조회 및 확정Node 18+ / Express
B. 가맹점 프론트엔드팝업 실행, 완료 감지, 백엔드 결과 확정 요청Vanilla JS
C. 가맹점 바이오 API 서버Ed25519 서명 검증 + 등록 데이터 저장/조회Node 18+ / Express

예제의 범위#

이 문서는 연동 흐름을 설명하기 위한 최소 구현 예제를 제공합니다. 요청·응답 필드, 오류 코드, 세션 상태의 기준은 OmniID API, 가맹점 바이오 API, 상태 및 오류 처리를 따릅니다. 운영 환경에 필요한 저장소, 로깅, 동시성 보강 사항은 코드 주석에서 안내합니다.

사전 준비#

  • 온보딩을 완료하고 merchant_id와 OAuth client_id/client_secret을 발급받아야 합니다 (가이드 §1).
  • Part C의 가맹점 바이오 API 서버를 HTTPS로 공개하고 Base URL을 옴니ID에 등록해야 합니다.
  • 연동 환경에 맞는 옴니ID API Base URL을 사용해야 합니다 (test: https://api.test.omniid.ai.kr, production: https://api.omniid.ai.kr).

공통 환경 변수:

NODE_ENV=development                       # 로컬 예제. 운영으로 실행 금지
ALLOW_IN_MEMORY_EXAMPLE=1                  # 로컬 메모리 adapter 명시적 허용
OMNIID_API_BASE=https://api.test.omniid.ai.kr  # test. 운영 전환 시 https://api.omniid.ai.kr
OMNIID_MERCHANT_ID=mer_xxx
OMNIID_CLIENT_ID=client_xxx
OMNIID_CLIENT_SECRET=...                  # 서버에만. 절대 브라우저 노출 금지
OMNIID_RETURN_URL=https://merchant.example.com/bio/complete  # PC popup target origin 고정용

Part A. 가맹점 백엔드#

프론트엔드에서 호출하는 세션 시작(/bio/start)과 결과 확정(/bio/finalize) 엔드포인트를 구현합니다. 세션 생성과 결과 조회에는 client_secret이 필요하므로 두 작업은 백엔드에서 처리해야 합니다. requireMerchantLogin은 가맹점의 기존 로그인 세션에 연결하는 어댑터입니다. 사용자 식별값은 브라우저 요청이 아니라 인증된 서버 세션에서 가져와야 합니다.

// backend.mjs — node backend.mjs  (Node 18+, `npm i express`)
import express from 'express';
const {
  NODE_ENV, ALLOW_IN_MEMORY_EXAMPLE, OMNIID_API_BASE, OMNIID_MERCHANT_ID, OMNIID_CLIENT_ID,
  OMNIID_CLIENT_SECRET, OMNIID_RETURN_URL,
} = process.env;

for (const name of [
  'OMNIID_API_BASE', 'OMNIID_MERCHANT_ID', 'OMNIID_CLIENT_ID',
  'OMNIID_CLIENT_SECRET', 'OMNIID_RETURN_URL',
]) {
  if (!process.env[name]) throw new Error(`${name} 환경변수가 필요합니다.`);
}
if (!['development', 'test'].includes(NODE_ENV) || ALLOW_IN_MEMORY_EXAMPLE !== '1') {
  throw new Error('이 코드의 인메모리 adapter는 로컬 예제에서만 사용할 수 있습니다.');
}
if (new URL(OMNIID_RETURN_URL).protocol !== 'https:') {
  throw new Error('OMNIID_RETURN_URL은 전체 HTTPS URL이어야 합니다.');
}

async function fetchJson(url, init, timeoutMs = 10_000) {
  let response;
  try { response = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }); }
  catch (cause) {
    const error = new Error('OmniID API 연결 오류', { cause });
    error.retryable = true;
    throw error;
  }
  let body;
  try { body = await response.json(); } catch { throw new Error(`잘못된 JSON 응답: ${response.status}`); }
  if (!response.ok) {
    const error = new Error(`OmniID API 오류: ${response.status}`);
    error.retryable = response.status >= 500;
    throw error;
  }
  return body;
}

async function fetchJsonWithRetry(url, init, timeoutMs = 10_000) {
  for (let attempt = 0; ; attempt++) {
    try { return await fetchJson(url, init, timeoutMs); }
    catch (error) {
      if (attempt >= 1 || error?.retryable !== true) throw error;
    }
  }
}

// --- OAuth 토큰: 만료 전까지 캐시 (현재 test/production은 client_id별 60초에 30회) ---
let tokenCache = null; // { value, expiresAtMs }
let tokenPromise = null; // 동시 요청은 하나의 갱신만 기다린다.
async function getAccessToken() {
  if (tokenCache && Date.now() < tokenCache.expiresAtMs - 30_000) return tokenCache.value;
  if (!tokenPromise) {
    tokenPromise = (async () => {
      const body = await fetchJson(`${OMNIID_API_BASE}/oauth/token`, {
        method: 'POST',
        headers: { 'content-type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams({
          grant_type: 'client_credentials',
          client_id: OMNIID_CLIENT_ID,
          client_secret: OMNIID_CLIENT_SECRET,
          scope: 'enrollment:create verification:create result:read',
        }),
      }, 5_000);
      if (typeof body.access_token !== 'string' || !Number.isFinite(body.expires_in)) {
        throw new Error('token 응답 형식이 올바르지 않습니다.');
      }
      tokenCache = { value: body.access_token, expiresAtMs: Date.now() + body.expires_in * 1000 };
      return tokenCache.value;
    })();
  }
  try { return await tokenPromise; } finally { tokenPromise = null; }
}

// 등록 성공 시 result.finger 를 사용자별로 보관해야 확인 세션을 만들 수 있다.
// 아래 Map 두 개는 로컬 예제 전용이다. 운영에서는 사용자 DB와 TTL 있는 공유 저장소를 쓴다.
const enrolledFinger = new Map(); // merchant_user_ref -> finger
const activeSessions = new Map(); // session_id -> { userRef, mode, forgetAtMs }
const app = express();
app.use(express.json({ limit: '16kb' }));

// 반드시 가맹점의 기존 인증 middleware 뒤에 둔다.
// 아래 두 줄의 adapter를 연결하지 않으면 예제를 기동하지 않는다.
const sessionMiddleware = globalThis.yourExistingSessionMiddleware;
const csrfMiddleware = globalThis.yourExistingCsrfMiddleware;
if (typeof sessionMiddleware !== 'function' || typeof csrfMiddleware !== 'function') {
  throw new Error('기존 로그인 세션과 CSRF middleware를 연결하세요.');
}
app.use(sessionMiddleware, csrfMiddleware);
function requireMerchantLogin(req, res, next) {
  const userRef = req.session?.user?.omniUserRef;
  if (typeof userRef !== 'string' || !userRef || userRef.length > 256) {
    return res.status(401).json({ error: 'login_required' });
  }
  req.authenticatedUserRef = userRef; // 서버가 확정한 현재 로그인 사용자
  next();
}

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

// 1) 프론트: 등록/확인 시작 → popup_url 반환
app.post('/bio/start', requireMerchantLogin, async (req, res) => {
  const { mode, startRequestId } = req.body;
  if (!['enroll', 'verify'].includes(mode) || !UUID.test(startRequestId ?? '')) {
    return res.status(400).json({ error: 'invalid_request' });
  }
  const userRef = req.authenticatedUserRef;
  const path = mode === 'enroll' ? '/v1/enrollment-sessions' : '/v1/verification-sessions';
  const payload = {
    merchant_id: OMNIID_MERCHANT_ID,
    merchant_user_ref: userRef,
    return_url: OMNIID_RETURN_URL, // PC popup targetOrigin 고정용(모바일 자동 복귀용 아님)
  };
  if (mode === 'verify') {
    const finger = enrolledFinger.get(userRef);
    if (!finger) return res.status(409).json({ error: 'not_enrolled' });
    payload.finger = finger; // 확인 세션은 finger 필수
  }
  try {
    const requestBody = JSON.stringify(payload);
    const s = await fetchJsonWithRetry(`${OMNIID_API_BASE}${path}`, {
      method: 'POST',
      headers: {
        authorization: `Bearer ${await getAccessToken()}`,
        'content-type': 'application/json',
        // 연결 오류/5xx 1회 재시도에도 같은 key/body를 사용한다.
        'idempotency-key': startRequestId,
      },
      body: requestBody,
    });
    if (typeof s.session_id !== 'string' || typeof s.popup_url !== 'string') {
      throw new Error('세션 응답 형식이 올바르지 않습니다.');
    }
    const expiresAtMs = Date.parse(s.expires_at);
    activeSessions.set(s.session_id, {
      userRef, mode,
      forgetAtMs: (Number.isFinite(expiresAtMs) ? expiresAtMs : Date.now() + 10 * 60_000) + 60 * 60_000,
    });
    res.json({
      sessionId: s.session_id, popupUrl: s.popup_url, mode,
      expiresAt: s.expires_at,
    });
  } catch {
    res.status(502).json({ error: 'omni_session_create_failed' });
  }
});

// 2) 프론트: 완료 감지 후 확정 요청 → OmniID 결과 조회로 최종 판정
app.post('/bio/finalize', requireMerchantLogin, async (req, res) => {
  const { sessionId } = req.body;
  const binding = typeof sessionId === 'string' ? activeSessions.get(sessionId) : null;
  if (!binding) return res.status(404).json({ error: 'unknown_session' });
  if (binding.userRef !== req.authenticatedUserRef) {
    return res.status(403).json({ error: 'session_user_mismatch' });
  }
  const url = `${OMNIID_API_BASE}/v1/sessions/${encodeURIComponent(sessionId)}/result`
            + `?merchant_user_ref=${encodeURIComponent(binding.userRef)}`;
  try {
    const result = await fetchJson(url, {
      headers: { authorization: `Bearer ${await getAccessToken()}` },
    });
    res.json(decide(result, binding));
  } catch {
    res.status(502).json({ error: 'omni_result_failed' });
  }
});

// 상태 및 오류 처리 문서의 세션 상태 분류를 코드로 옮긴다.
const NON_FINAL = ['created', 'opened', 'app_launched', 'callback_received', 'processing'];
const FINGERS = ['LI', 'LM', 'RI', 'RM'];
function decide(r, binding) {
  if (!r || r.mode !== binding.mode) throw new Error('결과 mode 불일치');
  if (NON_FINAL.includes(r.status)) return { kind: 'pending' }; // 잠시 후 재요청
  if (r.status === 'succeeded' && r.mode === 'enroll') {
    if (!FINGERS.includes(r.result?.finger)) throw new Error('등록 결과 형식 오류');
    enrolledFinger.set(binding.userRef, r.result.finger); // 확인 세션에 필요
    return { kind: 'enroll_ok', finger: r.result.finger };
  }
  if (r.status === 'succeeded' && r.mode === 'verify') {
    if (typeof r.result?.matched !== 'boolean') throw new Error('확인 결과 형식 오류');
    return { kind: r.result.matched ? 'verify_ok' : 'verify_mismatch' };
  }
  if (['failed', 'expired', 'canceled'].includes(r.status)) {
    return { kind: r.status, failureCode: r.failure_code };
  }
  throw new Error('알 수 없는 결과 상태');
}

setInterval(() => {
  for (const [id, session] of activeSessions) {
    if (Date.now() > session.forgetAtMs) activeSessions.delete(id);
  }
}, 60_000).unref();

// express.json() 파싱/크기 오류도 HTML 대신 일관된 JSON으로 반환한다.
app.use((error, req, res, next) => {
  if (!error) return next();
  return res.status(400).json({ error: 'invalid_request' });
});

app.listen(8080, () => console.log('merchant backend on :8080'));

Part B. 가맹점 프론트엔드#

프론트엔드 연동은 버튼 선택 → 빈 팝업 선점 → 백엔드에 세션 시작 요청 → popup_url로 이동 → 완료 감지(postMessage, 모바일 복귀, polling) → 백엔드 결과 확정 → 화면 반영 순서로 진행됩니다. 사용자 로그인 여부는 백엔드의 기존 세션으로 확인하며, 프론트엔드는 merchant_user_ref를 전송하지 않습니다.

<!-- Cookie 로그인이라면 실제 CSRF 토큰을 서버가 주입한다. -->
<meta name="csrf-token" content="SERVER_RENDERED_CSRF_TOKEN">
<button id="enroll">지문 등록</button>
<button id="verify">지문 인증</button>
<p id="status"></p>
<p id="manual"></p>

<script>
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
const finalKinds = new Set(['enroll_ok', 'verify_ok', 'verify_mismatch', 'failed', 'expired', 'canceled']);
let activeCleanup = null;

async function postJson(path, body) {
  const response = await fetch(path, {
    method: 'POST',
    headers: { 'content-type': 'application/json', 'x-csrf-token': csrfToken },
    credentials: 'same-origin',
    body: JSON.stringify(body),
  });
  let out;
  try { out = await response.json(); } catch { throw new Error('잘못된 서버 응답'); }
  if (!response.ok) throw new Error(out?.error || `HTTP ${response.status}`);
  return out;
}

async function run(mode) {
  if (activeCleanup) return; // 한 화면에서는 한 세션만 진행한다.
  setStatus('세션 생성 중…');
  setManualLink(null);
  setBusy(true);

  // 1) iOS Safari 팝업 차단 방지: 클릭 핸들러 안에서 빈 창을 먼저 연다.
  const popup = window.open('', 'omni-bio', 'width=460,height=760');
  const startRequestId = crypto.randomUUID(); // 같은 실행의 start 재시도에는 이 값을 재사용
  let sessionId = null;
  let hostedOrigin = null;
  let resultDeadlineMs = Date.now() + 11 * 60_000;
  let finished = false;
  let checking = false;
  let poll = 0;

  const cleanup = () => {
    window.removeEventListener('message', onMessage);
    window.removeEventListener('focus', onReturnToPage);
    window.removeEventListener('pageshow', onReturnToPage);
    document.removeEventListener('visibilitychange', onVisibility);
    clearInterval(poll);
    if (activeCleanup === cleanup) activeCleanup = null;
    setBusy(false);
  };
  activeCleanup = cleanup;

  const checkResult = async () => {
    if (!sessionId || finished || checking) return;
    if (Date.now() > resultDeadlineMs) {
      finished = true;
      cleanup();
      setStatus('결과 확인 시간이 지났습니다 — 새로 시작해 주세요');
      return;
    }
    checking = true;
    setStatus('결과 확인 중…');
    try {
      // 확정은 반드시 Backend 결과 조회로 — 팝업/복귀 신호는 조회 트리거일 뿐
      const out = await postJson('/bio/finalize', { sessionId });
      if (out.kind === 'pending') {
        setStatus('처리 중 — 잠시 후 다시 확인됩니다');
        return;
      }
      if (!finalKinds.has(out.kind)) throw new Error('알 수 없는 결과 상태');
      finished = true;
      render(out);
      cleanup();
      if (popup && !popup.closed) popup.close();
    } catch {
      // 일시적인 Backend/네트워크 오류는 최종 실패로 오판정하지 않고 polling을 계속한다.
      setStatus('결과 확인이 지연되고 있습니다 — 자동으로 다시 확인합니다');
    } finally {
      checking = false;
    }
  };

  // 완료 신호(PC 팝업). origin 은 Hosted UI origin으로 검증한다.
  function onMessage(e) {
    if (!hostedOrigin || e.origin !== hostedOrigin || e.source !== popup) return;
    const d = e.data;
    if (!d || d.source !== 'omniid' || d.type !== 'omniid:result') return;
    if (d.session_id !== sessionId || d.mode !== mode) return;
    checkResult();
  }
  function onReturnToPage() {
    checkResult();
  }
  function onVisibility() {
    if (document.visibilityState === 'visible') checkResult();
  }
  window.addEventListener('message', onMessage);
  window.addEventListener('focus', onReturnToPage);
  window.addEventListener('pageshow', onReturnToPage);
  document.addEventListener('visibilitychange', onVisibility);
  poll = setInterval(checkResult, 3000);

  try {
    // 2) Backend가 popup_url 발급 (client_secret 은 서버에만)
    const start = await postJson('/bio/start', { mode, startRequestId });
    if (!start.popupUrl || !start.sessionId || start.mode !== mode) throw new Error('세션 생성 실패');

    sessionId = start.sessionId;
    const popupUrl = new URL(start.popupUrl);
    if (popupUrl.protocol !== 'https:') throw new Error('Hosted UI URL 오류');
    hostedOrigin = popupUrl.origin;
    const expiresAtMs = Date.parse(start.expiresAt);
    if (Number.isFinite(expiresAtMs)) resultDeadlineMs = expiresAtMs + 60_000;
    if (popup && !popup.closed) popup.location.href = start.popupUrl;
    else setManualLink(start.popupUrl); // 완전 차단 시 사용자가 직접 열 수 있게 제공
  } catch (e) {
    cleanup();
    if (popup && !popup.closed) popup.close();
    setStatus('세션 생성 실패');
  }
}

function render(out) {
  const msg = {
    enroll_ok: '등록 완료',
    verify_ok: '인증 성공',
    verify_mismatch: '지문 불일치 — 다시 시도해 주세요',
    failed: '처리 실패',
    expired: '시간 초과 — 다시 시도해 주세요',
    canceled: '취소됨',
  }[out.kind];
  setStatus(msg);
}
function setStatus(t) { document.getElementById('status').textContent = t; }
function setBusy(busy) {
  document.getElementById('enroll').disabled = busy;
  document.getElementById('verify').disabled = busy;
}
function setManualLink(url) {
  const box = document.getElementById('manual');
  box.replaceChildren();
  if (!url) return;
  const a = document.createElement('a');
  a.href = url;
  a.target = 'omni-bio';
  a.rel = 'noopener';
  a.textContent = '인증 화면 열기';
  box.appendChild(a);
}

document.getElementById('enroll').onclick = () => run('enroll');
document.getElementById('verify').onclick = () => run('verify');
</script>

Part C. 가맹점 바이오 API 서버#

옴니ID가 등록 데이터 저장 및 조회를 위해 Ed25519로 서명한 요청을 보내는 API 서버입니다. 전체 요청·응답 계약은 가맹점 바이오 API를 따릅니다. 아래 서명 검증 코드는 해당 문서의 Verification Rules 순서로 구현합니다.

// bio-api.mjs — node bio-api.mjs  (Node 18+, `npm i express`)
import express from 'express';
import { createHash, createPublicKey, randomUUID, verify } from 'node:crypto';

const { NODE_ENV, ALLOW_IN_MEMORY_EXAMPLE, OMNIID_API_BASE, OMNIID_MERCHANT_ID } = process.env;
if (!OMNIID_API_BASE || !OMNIID_MERCHANT_ID) throw new Error('OMNIID_API_BASE와 OMNIID_MERCHANT_ID가 필요합니다.');
if (new URL(OMNIID_API_BASE).protocol !== 'https:') throw new Error('OMNIID_API_BASE는 HTTPS여야 합니다.');
if (!['development', 'test'].includes(NODE_ENV) || ALLOW_IN_MEMORY_EXAMPLE !== '1') {
  throw new Error('이 코드의 인메모리 adapter는 로컬 예제에서만 사용할 수 있습니다.');
}

const TIMESTAMP_TOLERANCE_MS = 5 * 60_000;
const KEY_CACHE_MS = 10 * 60_000;
const NONCE_TTL_MS = 10 * 60_000;
const IDEM_TTL_MS = 24 * 60 * 60_000;
const FINGERS = new Set(['LI', 'LM', 'RI', 'RM']);
const sha256Hex = (buf) => createHash('sha256').update(buf).digest('hex');

// --- OmniID 공개키 캐시: status/algorithm/key type까지 검증한다. ---
let keyCache = { at: 0, byId: new Map() };
let keyRefreshPromise = null;

function decodeBase64(value) {
  if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64');
  return bytes.length ? bytes : null;
}

async function refreshPublicKeys() {
  if (!keyRefreshPromise) {
    keyRefreshPromise = (async () => {
      const res = await fetch(`${OMNIID_API_BASE}/v1/signing-keys`, {
        signal: AbortSignal.timeout(5_000),
      });
      if (!res.ok) throw new Error(`signing-keys 조회 실패: ${res.status}`);
      const body = await res.json();
      if (!Array.isArray(body.keys)) throw new Error('signing-keys 응답 형식 오류');

      const byId = new Map();
      for (const item of body.keys) {
        if (!item || typeof item.key_id !== 'string' || item.algorithm !== 'ed25519') continue;
        if (!['active', 'retiring'].includes(item.status)) continue;
        const der = decodeBase64(item.public_key_base64);
        if (!der) continue;
        try {
          const key = createPublicKey({ key: der, format: 'der', type: 'spki' });
          if (key.asymmetricKeyType === 'ed25519') byId.set(item.key_id, key);
        } catch { /* 잘못된 key entry는 사용하지 않는다. */ }
      }
      if (byId.size === 0) throw new Error('사용 가능한 Ed25519 공개키가 없습니다.');
      keyCache = { at: Date.now(), byId };
    })();
  }
  try { await keyRefreshPromise; } finally { keyRefreshPromise = null; }
}

async function getPublicKey(keyId) {
  let refreshed = false;
  if (Date.now() - keyCache.at > KEY_CACHE_MS) {
    await refreshPublicKeys();
    refreshed = true;
  }
  let key = keyCache.byId.get(keyId);
  if (!key && !refreshed) {
    // 정상 rotation 시 새 key_id를 즉시 받아들이기 위해 unknown key는 한 번 재조회한다.
    // 운영에서의 남용 제한은 이 코드 밖의 ingress/rate limiter에서 적용한다.
    await refreshPublicKeys();
    key = keyCache.byId.get(keyId);
  }
  return key ?? null;
}

// 로컬 예제용 저장소. 운영에서는 원자적 TTL을 지원하는 공유 저장소/DB로 교체한다.
const seenNonces = new Map();     // nonce -> expiresAtMs
const store = new Map();          // [userRef, finger] -> enrollment
const idemReplies = new Map();    // Idempotency-Key -> { bodyHash, reply, expiresAtMs }
function fail(req, res, status, code) {
  const requestId = typeof req.headers['x-request-id'] === 'string'
    ? req.headers['x-request-id'] : `req_${randomUUID()}`;
  res.set('X-Request-Id', requestId);
  return res.status(status).json({ error: { code, message: code, request_id: requestId } });
}

function isText(value, max = 4096) {
  return typeof value === 'string' && value.length > 0 && value.length <= max;
}
function bodyError(body, mode) {
  if (!body || typeof body !== 'object' || Array.isArray(body)) return 'invalid_request';
  if (body.merchant_id !== OMNIID_MERCHANT_ID) return 'invalid_request';
  if (!isText(body.merchant_user_ref, 256) || body.provider_id !== 'pointlink') return 'invalid_request';
  if (body.type !== 'fingerprint') return 'unsupported_bio_type';
  if (!FINGERS.has(body.finger)) return 'unsupported_finger';
  const sessionField = mode === 'store' ? 'enrollment_session_id' : 'verification_session_id';
  if (!isText(body[sessionField], 128)) return 'invalid_request';
  if (mode === 'store' && (!isText(body.features, 1_000_000) || !isText(body.enc_key, 1_000_000))) {
    return 'invalid_request';
  }
  if (
    body.captured_at !== undefined &&
    (typeof body.captured_at !== 'string' || !Number.isFinite(Date.parse(body.captured_at)))
  ) return 'invalid_request';
  return null;
}

// raw body 필수: 서명은 OmniID가 보낸 원본 바이트 기준이라 재직렬화하면 깨진다.
async function verifySignature(req, res, next) {
  const h = req.headers;
  const [merchantId, keyId, ts, nonce, contentSha, sig] = [
    h['x-omniid-merchant-id'], h['x-omniid-key-id'], h['x-omniid-timestamp'],
    h['x-omniid-nonce'], h['x-omniid-content-sha256'], h['x-omniid-signature'],
  ];
  // 1. 필수 헤더
  if (![merchantId, keyId, ts, nonce, contentSha, sig].every(v => typeof v === 'string')) {
    return fail(req, res, 401, 'missing_signature');
  }
  if (!Buffer.isBuffer(req.body)) return fail(req, res, 400, 'invalid_request');
  try {
    // 2. key_id로 OmniID 공개키
    const publicKey = await getPublicKey(keyId);
    if (!publicKey) return fail(req, res, 401, 'invalid_signature');
    // 3. 우리에게 발급된 merchant_id인지
    if (merchantId !== OMNIID_MERCHANT_ID) return fail(req, res, 401, 'invalid_signature');
    // 4. timestamp 허용 오차 5분
    const skew = Math.abs(Date.now() - Date.parse(ts));
    if (!Number.isFinite(skew) || skew > TIMESTAMP_TOLERANCE_MS) {
      return fail(req, res, 401, 'timestamp_out_of_range');
    }
    // 5. nonce 재사용 거부. 검증 성공 뒤 저장하며 저장소 장애 시 반드시 fail-closed한다.
    const nonceExpiry = seenNonces.get(nonce);
    if (nonceExpiry && nonceExpiry > Date.now()) return fail(req, res, 409, 'nonce_reused');
    // 6. raw body 해시 일치
    if (!/^[0-9a-f]{64}$/.test(contentSha) || sha256Hex(req.body) !== contentSha) {
      return fail(req, res, 401, 'signature_mismatch');
    }
    // 7-8. canonical 재구성 후 Ed25519 검증
    const canonical = [
      'OMNIID-ED25519', req.method.toUpperCase(), req.originalUrl, ts, nonce, contentSha,
    ].join('\n');
    const signature = sig.startsWith('v2=') ? decodeBase64(sig.slice(3)) : null;
    if (!signature) return fail(req, res, 401, 'invalid_signature');
    let ok = false;
    try { ok = verify(null, Buffer.from(canonical), publicKey, signature); }
    catch { ok = false; }
    if (!ok) return fail(req, res, 401, 'signature_mismatch');

    seenNonces.set(nonce, Date.now() + NONCE_TTL_MS);
    try { req.json = JSON.parse(req.body.toString('utf8')); }
    catch { return fail(req, res, 400, 'invalid_request'); }
    next();
  } catch {
    return fail(req, res, 500, 'merchant_bio_internal_error');
  }
}

const app = express();
app.use(express.raw({ type: 'application/json', limit: '2mb' }));

// 등록 데이터 저장
app.post('/omni/bio/v1/enrollments', verifySignature, (req, res) => {
  const b = req.json;
  const idem = req.headers['idempotency-key'];
  const code = bodyError(b, 'store');
  if (code) return fail(req, res, 400, code);
  if (!isText(idem, 128) || idem !== b.enrollment_session_id) {
    return fail(req, res, 400, 'invalid_request');
  }
  const bodyHash = sha256Hex(req.body);
  const cached = idemReplies.get(idem);
  if (cached && cached.expiresAtMs > Date.now()) {
    if (cached.bodyHash !== bodyHash) return fail(req, res, 409, 'idempotency_conflict');
    return res.json(cached.reply);
  }

  const storedAt = new Date().toISOString();
  const ref = `bio_enr_${randomUUID()}`;
  store.set(JSON.stringify([b.merchant_user_ref, b.finger]), {
    features: b.features, encKey: b.enc_key, ref, enrolledAt: storedAt,
  });
  const reply = {
    stored: true, merchant_user_ref: b.merchant_user_ref, provider_id: b.provider_id,
    type: b.type, finger: b.finger, enrollment_ref: ref, stored_at: storedAt,
  };
  idemReplies.set(idem, { bodyHash, reply, expiresAtMs: Date.now() + IDEM_TTL_MS });
  res.json(reply);
});

// 확인 시 기존 등록 데이터 조회
app.post('/omni/bio/v1/enrollments/lookup', verifySignature, (req, res) => {
  const b = req.json;
  const code = bodyError(b, 'lookup');
  if (code) return fail(req, res, 400, code);
  const found = store.get(JSON.stringify([b.merchant_user_ref, b.finger]));
  if (!found) return fail(req, res, 404, 'bio_enrollment_not_found');
  res.json({
    found: true, merchant_user_ref: b.merchant_user_ref, provider_id: b.provider_id,
    type: b.type, finger: b.finger, features: found.features, enc_key: found.encKey,
    enrollment_ref: found.ref, enrolled_at: found.enrolledAt,
  });
});

setInterval(() => {
  const now = Date.now();
  for (const [nonce, expiresAt] of seenNonces) if (expiresAt <= now) seenNonces.delete(nonce);
  for (const [key, value] of idemReplies) if (value.expiresAtMs <= now) idemReplies.delete(key);
}, 60_000).unref();

// express.raw() 파싱/크기 오류도 공통 JSON error envelope로 반환한다.
app.use((error, req, res, next) => {
  if (!error) return next();
  return fail(req, res, 400, 'invalid_request');
});

// 로그에는 method/path/status만 — request/response body 금지
app.listen(4100, () => console.log('merchant bio api on :4100'));

서명 검증 주의사항#

  • 해시는 raw body를 기준으로 계산합니다. express.json()으로 파싱한 뒤 재직렬화하면 바이트가 달라져 signature_mismatch가 발생합니다. 위 예제는 express.raw()로 원본 바이트를 유지합니다.
  • canonical 문자열의 {path_and_query}에는 옴니ID가 호출한 전체 경로가 들어갑니다. Base URL에 경로가 포함된 경우도 반영해야 합니다(예: /omni/bio/v1/enrollments). 라우터를 하위 경로에 마운트한 경우 req.originalUrl을 사용하여 옴니ID가 서명한 값과 일치시킵니다.
  • 공개키는 Base64로 인코딩한 SPKI DER 형식으로 제공됩니다. Node.js에서는 createPublicKey({ format:'der', type:'spki' })로 로드합니다.
  • 알 수 없는 key_id를 수신하면 키 교체로 추가된 값일 수 있으므로 /v1/signing-keys를 다시 조회합니다. 위 예제의 캐시는 조회 결과에 키가 없을 때 한 번 갱신합니다.