@sayren/store-sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +5338 -0
- package/dist/index.mjs +2300 -0
- package/dist/server.d.mts +59 -0
- package/dist/server.mjs +125 -0
- package/dist/webhook-headers-D6QmPdEJ.d.mts +22 -0
- package/dist/webhook-headers-ap_exsb9.mjs +22 -0
- package/package.json +48 -0
- package/src/base64.ts +28 -0
- package/src/client.ts +877 -0
- package/src/http.ts +194 -0
- package/src/index.ts +35 -0
- package/src/oauth.ts +132 -0
- package/src/schemas/api-client.ts +121 -0
- package/src/schemas/api-log.ts +104 -0
- package/src/schemas/auth.ts +81 -0
- package/src/schemas/category.ts +98 -0
- package/src/schemas/claim.ts +89 -0
- package/src/schemas/common.ts +61 -0
- package/src/schemas/misc.ts +298 -0
- package/src/schemas/order.ts +233 -0
- package/src/schemas/payment.ts +582 -0
- package/src/schemas/product.ts +272 -0
- package/src/schemas/scope.ts +75 -0
- package/src/schemas/store.ts +143 -0
- package/src/schemas/upload.ts +23 -0
- package/src/schemas/webhook-event.ts +145 -0
- package/src/server.ts +24 -0
- package/src/session.ts +249 -0
- package/src/webhook-headers.ts +21 -0
- package/src/webhook-signature.ts +177 -0
package/src/session.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { base64UrlFromBytes } from "./base64";
|
|
2
|
+
import { toApiError, unwrapData } from "./http";
|
|
3
|
+
import {
|
|
4
|
+
buildAuthorizeUrl,
|
|
5
|
+
createPkcePair,
|
|
6
|
+
exchangeAuthorizationCode,
|
|
7
|
+
refreshUserToken,
|
|
8
|
+
} from "./oauth";
|
|
9
|
+
import { type MyAccount, myAccountSchema } from "./schemas/auth";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* SDK 세션 매니저 — 콘솔(userToken)의 저장·무음 갱신·회전을 캡슐화한다.
|
|
13
|
+
*
|
|
14
|
+
* 저장 매체(쿠키/localStorage/…)와 그 속성(Max-Age·SameSite·Secure·Path)은 **어댑터(앱) 책임**이다.
|
|
15
|
+
* SDK는 "무엇을 어떤 키로 저장할지"만 알고, "어떻게 저장할지"는 SessionStorage 어댑터에 위임한다.
|
|
16
|
+
* 쿠키는 동기 read/write라 인터페이스도 동기로 충분하다.
|
|
17
|
+
*/
|
|
18
|
+
export interface SessionStorage {
|
|
19
|
+
get(key: string): string | null;
|
|
20
|
+
set(key: string, value: string): void;
|
|
21
|
+
remove(key: string): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 세션 키 상수 — SDK가 소유한다. 값은 apps/store의 기존 쿠키 이름(SESSION_COOKIE/REFRESH_COOKIE)과
|
|
26
|
+
* 동일하게 맞춰, 앱 어댑터·SSR 라우트 가드가 같은 원천을 읽도록 패리티를 보장한다.
|
|
27
|
+
*/
|
|
28
|
+
export const STORE_SESSION_KEYS = {
|
|
29
|
+
session: "sayren.store.session",
|
|
30
|
+
refresh: "sayren.store.refresh",
|
|
31
|
+
/** authorize() → handleCallback() 간 1회용 PKCE 블롭({ verifier, state, redirectUri }) 저장 키 */
|
|
32
|
+
pkce: "sayren.store.pkce",
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
/** Web Locks 이름 — 탭 간 refresh 회전 직렬화용 */
|
|
36
|
+
const REFRESH_LOCK = STORE_SESSION_KEYS.refresh;
|
|
37
|
+
|
|
38
|
+
export interface StoreSessionOptions {
|
|
39
|
+
/** 예: https://api.example.com/v1 (/oauth2/token이 덧붙는다) */
|
|
40
|
+
baseUrl: string;
|
|
41
|
+
/** OAuth public client id (예: avarlabs) */
|
|
42
|
+
clientId: string;
|
|
43
|
+
/** 저장 매체 어댑터 (앱 소유) */
|
|
44
|
+
storage: SessionStorage;
|
|
45
|
+
fetch?: typeof globalThis.fetch;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface AuthorizeOptions {
|
|
49
|
+
/** OAuth 콜백 URI (예: https://store.example.com/auth/callback) */
|
|
50
|
+
redirectUri: string;
|
|
51
|
+
/** 생략 시 서버 기본(stores:read). refresh가 필요하면 offline_access 포함 */
|
|
52
|
+
scope?: string;
|
|
53
|
+
/** "signup"이면 authorize 대신 호스티드 회원가입 화면으로 진입한다 */
|
|
54
|
+
screen?: "authorize" | "signup";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface StoreSession {
|
|
58
|
+
/** 현재 userToken (없으면 null) */
|
|
59
|
+
getToken(): string | null;
|
|
60
|
+
/** 토큰 저장 + 구독자 통지. refresh_token이 있으면 함께 회전 저장한다(없으면 기존 유지) */
|
|
61
|
+
setTokens(tokens: { access_token: string; refresh_token?: string }): void;
|
|
62
|
+
/**
|
|
63
|
+
* 무음 세션 갱신 — refresh 토큰으로 새 userToken을 받아 회전 저장한다.
|
|
64
|
+
* - 탭 내부: 동시 다발 호출을 single-flight로 합친다.
|
|
65
|
+
* - 탭 간: navigator.locks(있으면)로 회전을 직렬화하고, 락 진입 시 다른 탭이 이미
|
|
66
|
+
* 회전했으면(세션 토큰 변경) 내 refresh 토큰을 소비하지 않고 true를 반환한다.
|
|
67
|
+
* refresh 토큰이 없으면 false(기존 세션 유지 — access token이 아직 유효할 수 있다).
|
|
68
|
+
* 회전이 실패(만료·재사용 폐기)하면 세션을 clear하고 false. (401 경로는 onUnauthorized→clear가 별도 처리.)
|
|
69
|
+
*/
|
|
70
|
+
refresh(): Promise<boolean>;
|
|
71
|
+
/** 세션/리프레시 키를 지우고 구독자에게 통지한다 */
|
|
72
|
+
clear(): void;
|
|
73
|
+
/** 변경 구독(useSyncExternalStore 소비용) — getToken을 스냅샷으로 쓴다. 해지 함수 반환 */
|
|
74
|
+
subscribe(cb: () => void): () => void;
|
|
75
|
+
/** 현재 userToken (getToken()과 동일 — 게터 형태 소비용) */
|
|
76
|
+
readonly accessToken: string | null;
|
|
77
|
+
/** 현재 저장된 refresh 토큰 (없으면 null) */
|
|
78
|
+
readonly refreshToken: string | null;
|
|
79
|
+
/**
|
|
80
|
+
* 로그인/가입 시작 — PKCE 페어를 생성해 storage에 저장하고 authorize URL을 만든다.
|
|
81
|
+
* 브라우저(window 존재)에서는 top-level navigation을 수행하고 void를 반환한다.
|
|
82
|
+
* 그 외(SSR/테스트)에서는 이동하지 않고 URL 문자열을 반환한다.
|
|
83
|
+
*/
|
|
84
|
+
authorize(options: AuthorizeOptions): Promise<string | undefined>;
|
|
85
|
+
/**
|
|
86
|
+
* OAuth 콜백 처리 — 저장된 PKCE 블롭(1회용)을 꺼내 state를 대조하고 code를 토큰으로 교환해 저장한다.
|
|
87
|
+
* PKCE 블롭이 없거나(만료) state가 불일치하면 던진다.
|
|
88
|
+
*/
|
|
89
|
+
handleCallback(code: string, state: string): Promise<void>;
|
|
90
|
+
/** GET /me — accessToken이 없으면 네트워크 없이 null */
|
|
91
|
+
userinfo(): Promise<MyAccount | null>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function createStoreSession(options: StoreSessionOptions): StoreSession {
|
|
95
|
+
const { baseUrl, clientId, storage } = options;
|
|
96
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
97
|
+
const listeners = new Set<() => void>();
|
|
98
|
+
|
|
99
|
+
function notify(): void {
|
|
100
|
+
for (const listener of listeners) listener();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function getToken(): string | null {
|
|
104
|
+
return storage.get(STORE_SESSION_KEYS.session);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function setTokens(tokens: { access_token: string; refresh_token?: string }): void {
|
|
108
|
+
storage.set(STORE_SESSION_KEYS.session, tokens.access_token);
|
|
109
|
+
if (tokens.refresh_token) storage.set(STORE_SESSION_KEYS.refresh, tokens.refresh_token);
|
|
110
|
+
notify();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function clear(): void {
|
|
114
|
+
storage.remove(STORE_SESSION_KEYS.session);
|
|
115
|
+
storage.remove(STORE_SESSION_KEYS.refresh);
|
|
116
|
+
notify();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let refreshInFlight: Promise<boolean> | null = null;
|
|
120
|
+
|
|
121
|
+
function refresh(): Promise<boolean> {
|
|
122
|
+
if (!refreshInFlight) {
|
|
123
|
+
refreshInFlight = doRefresh().finally(() => {
|
|
124
|
+
refreshInFlight = null;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return refreshInFlight;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function doRefresh(): Promise<boolean> {
|
|
131
|
+
// 락을 기다리는 동안 다른 탭이 회전을 끝낼 수 있으므로, 대기 전 세션 토큰을 스냅샷한다.
|
|
132
|
+
const before = getToken();
|
|
133
|
+
const locks = typeof navigator !== "undefined" ? navigator.locks : undefined;
|
|
134
|
+
return locks ? locks.request(REFRESH_LOCK, () => rotate(before)) : rotate(before);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function rotate(before: string | null): Promise<boolean> {
|
|
138
|
+
// 락 획득 후 세션 토큰이 바뀌었으면 다른 탭이 이미 회전한 것 → 내 refresh 토큰은 소비하지 않는다.
|
|
139
|
+
if (getToken() !== before) return true;
|
|
140
|
+
const refreshToken = storage.get(STORE_SESSION_KEYS.refresh);
|
|
141
|
+
if (!refreshToken) return false;
|
|
142
|
+
try {
|
|
143
|
+
const tokens = await refreshUserToken({ baseUrl, clientId, refreshToken, fetch: fetchFn });
|
|
144
|
+
setTokens({ access_token: tokens.access_token, refresh_token: tokens.refresh_token });
|
|
145
|
+
return true;
|
|
146
|
+
} catch {
|
|
147
|
+
clear(); // refresh 토큰까지 함께 폐기
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function subscribe(cb: () => void): () => void {
|
|
153
|
+
listeners.add(cb);
|
|
154
|
+
return () => {
|
|
155
|
+
listeners.delete(cb);
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** crypto.randomUUID가 있으면 그걸, 없으면 무작위 바이트를 base64url로 인코딩해 state를 만든다 */
|
|
160
|
+
function randomState(): string {
|
|
161
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
162
|
+
return crypto.randomUUID();
|
|
163
|
+
}
|
|
164
|
+
const bytes = new Uint8Array(16);
|
|
165
|
+
crypto.getRandomValues(bytes);
|
|
166
|
+
return base64UrlFromBytes(bytes);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
interface PkceBlob {
|
|
170
|
+
verifier: string;
|
|
171
|
+
state: string;
|
|
172
|
+
redirectUri: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function authorize(authorizeOptions: AuthorizeOptions): Promise<string | undefined> {
|
|
176
|
+
const { verifier, challenge } = await createPkcePair();
|
|
177
|
+
const state = randomState();
|
|
178
|
+
const blob: PkceBlob = { verifier, state, redirectUri: authorizeOptions.redirectUri };
|
|
179
|
+
storage.set(STORE_SESSION_KEYS.pkce, JSON.stringify(blob));
|
|
180
|
+
|
|
181
|
+
const url = new URL(
|
|
182
|
+
buildAuthorizeUrl({
|
|
183
|
+
baseUrl,
|
|
184
|
+
clientId,
|
|
185
|
+
redirectUri: authorizeOptions.redirectUri,
|
|
186
|
+
state,
|
|
187
|
+
codeChallenge: challenge,
|
|
188
|
+
scope: authorizeOptions.scope,
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
if (authorizeOptions.screen === "signup") {
|
|
192
|
+
url.pathname = url.pathname.replace(/\/oauth2\/authorize$/, "/oauth2/signup");
|
|
193
|
+
}
|
|
194
|
+
const finalUrl = url.toString();
|
|
195
|
+
|
|
196
|
+
if (typeof window !== "undefined") {
|
|
197
|
+
window.location.assign(finalUrl);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
return finalUrl;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function handleCallback(code: string, state: string): Promise<void> {
|
|
204
|
+
const raw = storage.get(STORE_SESSION_KEYS.pkce);
|
|
205
|
+
storage.remove(STORE_SESSION_KEYS.pkce); // 1회용
|
|
206
|
+
if (!raw) throw new Error("로그인 세션이 만료되었습니다. 다시 시도해주세요");
|
|
207
|
+
const saved = JSON.parse(raw) as PkceBlob;
|
|
208
|
+
if (saved.state !== state) {
|
|
209
|
+
throw new Error("state가 일치하지 않습니다. 다시 시도해주세요");
|
|
210
|
+
}
|
|
211
|
+
const tokens = await exchangeAuthorizationCode({
|
|
212
|
+
baseUrl,
|
|
213
|
+
clientId,
|
|
214
|
+
redirectUri: saved.redirectUri,
|
|
215
|
+
code,
|
|
216
|
+
codeVerifier: saved.verifier,
|
|
217
|
+
fetch: fetchFn,
|
|
218
|
+
});
|
|
219
|
+
setTokens({ access_token: tokens.access_token, refresh_token: tokens.refresh_token });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function userinfo(): Promise<MyAccount | null> {
|
|
223
|
+
const token = getToken();
|
|
224
|
+
if (!token) return null;
|
|
225
|
+
const response = await fetchFn(`${baseUrl}/me`, {
|
|
226
|
+
headers: { authorization: `Bearer ${token}` },
|
|
227
|
+
});
|
|
228
|
+
const json: unknown = await response.json().catch(() => null);
|
|
229
|
+
if (!response.ok) throw toApiError(response.status, json);
|
|
230
|
+
return myAccountSchema.parse(unwrapData(json));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
getToken,
|
|
235
|
+
setTokens,
|
|
236
|
+
refresh,
|
|
237
|
+
clear,
|
|
238
|
+
subscribe,
|
|
239
|
+
authorize,
|
|
240
|
+
handleCallback,
|
|
241
|
+
userinfo,
|
|
242
|
+
get accessToken(): string | null {
|
|
243
|
+
return getToken();
|
|
244
|
+
},
|
|
245
|
+
get refreshToken(): string | null {
|
|
246
|
+
return storage.get(STORE_SESSION_KEYS.refresh);
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** 웹훅 발송 요청 헤더 상수 (공개 계약). zod 의존 없이 서버 엔트리포인트에서도 가볍게 쓴다 */
|
|
2
|
+
|
|
3
|
+
/** 서명 헤더. 형식: `t=<unix초>,v1=<hex>[,v1=<hex>]` */
|
|
4
|
+
export const WEBHOOK_SIGNATURE_HEADER = "Sayren-Signature";
|
|
5
|
+
/** 이벤트 ID 헤더. 본문 `id`와 같다. 중복 제거 키 */
|
|
6
|
+
export const WEBHOOK_EVENT_ID_HEADER = "Sayren-Event-Id";
|
|
7
|
+
/** 이벤트 타입 헤더. 본문 `type`과 같다 */
|
|
8
|
+
export const WEBHOOK_EVENT_TYPE_HEADER = "Sayren-Event-Type";
|
|
9
|
+
/** 전송 기록 ID 헤더. 전송 기록의 `deliveryId`와 같다 */
|
|
10
|
+
export const WEBHOOK_DELIVERY_ID_HEADER = "Sayren-Delivery-Id";
|
|
11
|
+
/** 발송 요청의 `User-Agent` 값 */
|
|
12
|
+
export const WEBHOOK_USER_AGENT = "Sayren-Webhooks/1.0";
|
|
13
|
+
/** 수신 측 권장 타임스탬프 허용 오차(초) */
|
|
14
|
+
export const WEBHOOK_DEFAULT_TOLERANCE_SEC = 300;
|
|
15
|
+
|
|
16
|
+
export const WEBHOOK_HEADERS = {
|
|
17
|
+
signature: WEBHOOK_SIGNATURE_HEADER,
|
|
18
|
+
eventId: WEBHOOK_EVENT_ID_HEADER,
|
|
19
|
+
eventType: WEBHOOK_EVENT_TYPE_HEADER,
|
|
20
|
+
deliveryId: WEBHOOK_DELIVERY_ID_HEADER,
|
|
21
|
+
} as const;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { WEBHOOK_DEFAULT_TOLERANCE_SEC } from "./webhook-headers";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 웹훅 서명 (`Sayren-Signature`) 생성·파싱·검증.
|
|
5
|
+
*
|
|
6
|
+
* 형식: `t=<unix초>,v1=<hex>[,v1=<hex>]`. v1 = HMAC-SHA256(secret, `${t}.${rawBody}`)의 소문자 hex.
|
|
7
|
+
* 시크릿 교체 유예 중에는 현재·이전 시크릿 서명을 모두 싣는다(현재 먼저).
|
|
8
|
+
*
|
|
9
|
+
* WebCrypto(`crypto.subtle`)만 써서 Node·Workers·Deno 어디서나 동작한다. 키 import는 호출 시점에
|
|
10
|
+
* 하므로 모듈 평가에 부작용·top-level await가 없다(CJS `require(esm)` 소비 조건).
|
|
11
|
+
* 시크릿을 다루므로 서버 엔트리포인트(`@sayren/store-sdk/server`)로만 노출한다.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const encoder = new TextEncoder();
|
|
15
|
+
|
|
16
|
+
function subtle(): SubtleCrypto {
|
|
17
|
+
const api = globalThis.crypto?.subtle;
|
|
18
|
+
if (!api) throw new Error("WebCrypto(crypto.subtle)를 사용할 수 없는 런타임입니다");
|
|
19
|
+
return api;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function importHmacKey(secret: string, usage: "sign" | "verify"): Promise<CryptoKey> {
|
|
23
|
+
return subtle().importKey(
|
|
24
|
+
"raw",
|
|
25
|
+
encoder.encode(secret),
|
|
26
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
27
|
+
false,
|
|
28
|
+
[usage],
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function toHex(buffer: ArrayBuffer): string {
|
|
33
|
+
let hex = "";
|
|
34
|
+
for (const byte of new Uint8Array(buffer)) hex += byte.toString(16).padStart(2, "0");
|
|
35
|
+
return hex;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 64자 hex → 32바이트. 형식이 아니면 null */
|
|
39
|
+
function fromHex(hex: string): Uint8Array<ArrayBuffer> | null {
|
|
40
|
+
if (!/^[0-9a-fA-F]{64}$/.test(hex)) return null;
|
|
41
|
+
const bytes = new Uint8Array(32);
|
|
42
|
+
for (let i = 0; i < 32; i++) bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
43
|
+
return bytes;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function signedContent(timestamp: number, payload: string): Uint8Array<ArrayBuffer> {
|
|
47
|
+
return encoder.encode(`${timestamp}.${payload}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function toSecretList(secret: string | readonly string[]): string[] {
|
|
51
|
+
return (typeof secret === "string" ? [secret] : [...secret]).filter((s) => s.length > 0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface SignWebhookPayloadOptions {
|
|
55
|
+
/** 요청 본문 원문. 전송하는 바이트와 정확히 같아야 한다 */
|
|
56
|
+
payload: string;
|
|
57
|
+
/** 서명 시크릿. 여러 개면 순서대로 v1을 싣는다(현재 시크릿 먼저) */
|
|
58
|
+
secret: string | readonly string[];
|
|
59
|
+
/** 서명 시각 (unix 초). 생략하면 현재 시각 */
|
|
60
|
+
timestamp?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `Sayren-Signature` 헤더 값을 만든다 */
|
|
64
|
+
export async function signWebhookPayload(options: SignWebhookPayloadOptions): Promise<string> {
|
|
65
|
+
const secrets = toSecretList(options.secret);
|
|
66
|
+
if (secrets.length === 0) throw new Error("서명 시크릿이 비어 있습니다");
|
|
67
|
+
const timestamp = options.timestamp ?? Math.floor(Date.now() / 1000);
|
|
68
|
+
if (!Number.isSafeInteger(timestamp) || timestamp < 0) {
|
|
69
|
+
throw new Error("timestamp는 0 이상의 정수(unix 초)여야 합니다");
|
|
70
|
+
}
|
|
71
|
+
const content = signedContent(timestamp, options.payload);
|
|
72
|
+
const parts = [`t=${timestamp}`];
|
|
73
|
+
for (const secret of secrets) {
|
|
74
|
+
const key = await importHmacKey(secret, "sign");
|
|
75
|
+
parts.push(`v1=${toHex(await subtle().sign("HMAC", key, content))}`);
|
|
76
|
+
}
|
|
77
|
+
return parts.join(",");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ParsedWebhookSignature {
|
|
81
|
+
/** 서명 시각 (unix 초) */
|
|
82
|
+
timestamp: number;
|
|
83
|
+
/** v1 서명(hex) 목록. 헤더에 실린 순서 그대로 */
|
|
84
|
+
signatures: string[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* `Sayren-Signature` 헤더를 파싱한다. `t`가 정확히 1개, `v1`이 1개 이상이어야 하며
|
|
89
|
+
* 아니면 null. 알 수 없는 스킴(예: 향후 `v2`)은 무시한다.
|
|
90
|
+
*/
|
|
91
|
+
export function parseWebhookSignatureHeader(
|
|
92
|
+
header: string | null | undefined,
|
|
93
|
+
): ParsedWebhookSignature | null {
|
|
94
|
+
if (!header) return null;
|
|
95
|
+
let timestamp: number | null = null;
|
|
96
|
+
const signatures: string[] = [];
|
|
97
|
+
for (const raw of header.split(",")) {
|
|
98
|
+
const part = raw.trim();
|
|
99
|
+
const eq = part.indexOf("=");
|
|
100
|
+
if (eq <= 0) return null;
|
|
101
|
+
const scheme = part.slice(0, eq);
|
|
102
|
+
const value = part.slice(eq + 1);
|
|
103
|
+
if (scheme === "t") {
|
|
104
|
+
if (timestamp !== null || !/^\d{1,15}$/.test(value)) return null;
|
|
105
|
+
timestamp = Number(value);
|
|
106
|
+
} else if (scheme === "v1") {
|
|
107
|
+
if (!value) return null;
|
|
108
|
+
signatures.push(value);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (timestamp === null || signatures.length === 0) return null;
|
|
112
|
+
return { timestamp, signatures };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type WebhookSignatureFailureReason =
|
|
116
|
+
/** 헤더가 없거나 비어 있음 */
|
|
117
|
+
| "MISSING_HEADER"
|
|
118
|
+
/** 헤더 형식 오류 (`t` 누락·중복·비정수, `v1` 없음) */
|
|
119
|
+
| "MALFORMED_HEADER"
|
|
120
|
+
/** 서명 시각이 허용 오차를 벗어남 (재전송 공격 방지) */
|
|
121
|
+
| "TIMESTAMP_OUT_OF_TOLERANCE"
|
|
122
|
+
/** 어떤 시크릿으로도 일치하는 v1이 없음 (본문 변조·시크릿 불일치) */
|
|
123
|
+
| "SIGNATURE_MISMATCH";
|
|
124
|
+
|
|
125
|
+
export type WebhookSignatureVerification =
|
|
126
|
+
| { ok: true; timestamp: number }
|
|
127
|
+
| { ok: false; reason: WebhookSignatureFailureReason };
|
|
128
|
+
|
|
129
|
+
export interface VerifyWebhookSignatureOptions {
|
|
130
|
+
/** 요청 본문 원문. JSON 파싱 전 바이트 그대로의 문자열이어야 한다 */
|
|
131
|
+
payload: string;
|
|
132
|
+
/** `Sayren-Signature` 헤더 값 */
|
|
133
|
+
header: string | null | undefined;
|
|
134
|
+
/** 서명 시크릿. 교체 유예 중이면 새·이전 시크릿을 모두 넘긴다. 하나라도 일치하면 통과 */
|
|
135
|
+
secret: string | readonly string[];
|
|
136
|
+
/** 타임스탬프 허용 오차(초). 기본 300 */
|
|
137
|
+
toleranceSec?: number;
|
|
138
|
+
/** 기준 시각. 생략하면 현재 시각 */
|
|
139
|
+
now?: Date;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* `Sayren-Signature`를 검증한다. 비교는 `crypto.subtle.verify`(HMAC 상수 시간 비교)로 하며
|
|
144
|
+
* 헤더의 v1 중 하나가 시크릿 중 하나와 일치하면 통과한다.
|
|
145
|
+
* 실패해도 예외를 던지지 않고 `{ ok: false, reason }`을 돌려준다.
|
|
146
|
+
*/
|
|
147
|
+
export async function verifyWebhookSignature(
|
|
148
|
+
options: VerifyWebhookSignatureOptions,
|
|
149
|
+
): Promise<WebhookSignatureVerification> {
|
|
150
|
+
const secrets = toSecretList(options.secret);
|
|
151
|
+
if (secrets.length === 0) throw new Error("서명 시크릿이 비어 있습니다");
|
|
152
|
+
if (!options.header) return { ok: false, reason: "MISSING_HEADER" };
|
|
153
|
+
const parsed = parseWebhookSignatureHeader(options.header);
|
|
154
|
+
if (!parsed) return { ok: false, reason: "MALFORMED_HEADER" };
|
|
155
|
+
|
|
156
|
+
const tolerance = options.toleranceSec ?? WEBHOOK_DEFAULT_TOLERANCE_SEC;
|
|
157
|
+
const nowSec = Math.floor((options.now ?? new Date()).getTime() / 1000);
|
|
158
|
+
if (Math.abs(nowSec - parsed.timestamp) > tolerance) {
|
|
159
|
+
return { ok: false, reason: "TIMESTAMP_OUT_OF_TOLERANCE" };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const candidates = parsed.signatures
|
|
163
|
+
.map(fromHex)
|
|
164
|
+
.filter((bytes): bytes is Uint8Array<ArrayBuffer> => bytes !== null);
|
|
165
|
+
if (candidates.length > 0) {
|
|
166
|
+
const content = signedContent(parsed.timestamp, options.payload);
|
|
167
|
+
for (const secret of secrets) {
|
|
168
|
+
const key = await importHmacKey(secret, "verify");
|
|
169
|
+
for (const signature of candidates) {
|
|
170
|
+
if (await subtle().verify("HMAC", key, signature, content)) {
|
|
171
|
+
return { ok: true, timestamp: parsed.timestamp };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { ok: false, reason: "SIGNATURE_MISMATCH" };
|
|
177
|
+
}
|