가맹점 연동 가이드의 전체 흐름을 백엔드, 프론트엔드, 가맹점 바이오 API 서버로 나눈 통합 골격입니다. 예제 내 인메모리 저장소는 로컬 흐름 확인용이며, Part A는 가맹점의 기존 로그인 세션·CSRF middleware를 먼저 연결해야 실행됩니다.
| 구성 | 역할 | 구현 환경 |
|---|---|---|
| A. 가맹점 백엔드 | OAuth 토큰 발급 및 캐시, 세션 생성, 결과 조회 및 확정 | Node 18+ / Express |
| B. 가맹점 프론트엔드 | 팝업 실행, 완료 감지, 백엔드 결과 확정 요청 | Vanilla JS |
| C. 가맹점 바이오 API 서버 | Ed25519 서명 검증 + 등록 데이터 저장/조회 | Node 18+ / Express |
예제의 범위#
이 문서는 연동 흐름을 설명하기 위한 최소 구현 예제를 제공합니다. 요청·응답 필드, 오류 코드, 세션 상태의 기준은 OmniID API, 가맹점 바이오 API, 상태 및 오류 처리를 따릅니다. 운영 환경에 필요한 저장소, 로깅, 동시성 보강 사항은 코드 주석에서 안내합니다.
사전 준비#
- 온보딩을 완료하고
merchant_id와 OAuthclient_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를 다시 조회합니다. 위 예제의 캐시는 조회 결과에 키가 없을 때 한 번 갱신합니다.