@sayren/store-sdk 0.23.0 → 0.28.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 +770 -616
- package/dist/index.mjs +259 -236
- package/dist/server.d.mts +19 -28
- package/dist/server.mjs +29 -42
- package/package.json +1 -1
- package/src/client.ts +41 -139
- package/src/index.ts +3 -2
- package/src/member-delegation.ts +51 -77
- package/src/oauth.ts +28 -33
- package/src/schemas/auth.ts +0 -64
- package/src/schemas/customer-delegation.ts +48 -0
- package/src/schemas/product.ts +7 -0
- package/src/schemas/scope.ts +1 -2
- package/src/schemas/store.ts +1 -1
- package/src/schemas/storefront-hosting.ts +28 -0
- package/src/schemas/subscription.ts +222 -0
- package/src/server.ts +1 -2
- package/src/session.ts +46 -29
- package/src/schemas/api-client.ts +0 -121
package/src/session.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
import { type MyAccount, myAccountSchema } from "./schemas/auth";
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* SDK 세션 매니저 — 콘솔(
|
|
12
|
+
* SDK 세션 매니저 — 콘솔(액세스 토큰)의 저장·무음 갱신·회전을 캡슐화한다.
|
|
13
13
|
*
|
|
14
14
|
* 저장 매체(쿠키/localStorage/…)와 그 속성(Max-Age·SameSite·Secure·Path)은 **어댑터(앱) 책임**이다.
|
|
15
15
|
* SDK는 "무엇을 어떤 키로 저장할지"만 알고, "어떻게 저장할지"는 SessionStorage 어댑터에 위임한다.
|
|
@@ -38,9 +38,22 @@ const REVOKE_TIMEOUT_MS = 5_000;
|
|
|
38
38
|
/** Web Locks 이름 — 탭 간 refresh 회전 직렬화용 */
|
|
39
39
|
const REFRESH_LOCK = STORE_SESSION_KEYS.refresh;
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* 인가 서버(Sayren 계정, accounts) 주소 — `https://accounts.sayren.app/api/auth/oauth2/{authorize,token,revoke}`.
|
|
43
|
+
* `resource`를 주면 authorize·code 교환·refresh에 RFC 8707 `resource`를 싣는다(JWT access token의 `aud`).
|
|
44
|
+
*/
|
|
45
|
+
export interface StoreAuthServer {
|
|
46
|
+
authorizeUrl: string;
|
|
47
|
+
tokenUrl: string;
|
|
48
|
+
revokeUrl: string;
|
|
49
|
+
resource?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
41
52
|
export interface StoreSessionOptions {
|
|
42
|
-
/** 예: https://api.example.com/v1 (/
|
|
53
|
+
/** api 주소(예: https://api.example.com/v1) — `userinfo()`의 `GET /me`에 쓴다 */
|
|
43
54
|
baseUrl: string;
|
|
55
|
+
/** 인가 서버(accounts) 주소들 */
|
|
56
|
+
authServer: StoreAuthServer;
|
|
44
57
|
/** OAuth public client id (예: avarlabs) */
|
|
45
58
|
clientId: string;
|
|
46
59
|
/** 저장 매체 어댑터 (앱 소유) */
|
|
@@ -51,19 +64,19 @@ export interface StoreSessionOptions {
|
|
|
51
64
|
export interface AuthorizeOptions {
|
|
52
65
|
/** OAuth 콜백 URI (예: https://store.example.com/auth/callback) */
|
|
53
66
|
redirectUri: string;
|
|
54
|
-
/** 생략 시
|
|
67
|
+
/** 생략 시 발급자 기본. refresh가 필요하면 offline_access 포함 */
|
|
55
68
|
scope?: string;
|
|
56
|
-
/**
|
|
57
|
-
|
|
69
|
+
/** OIDC `prompt` — `login`(재인증)·`create`(가입)·`consent` */
|
|
70
|
+
prompt?: "login" | "create" | "consent";
|
|
58
71
|
}
|
|
59
72
|
|
|
60
73
|
export interface StoreSession {
|
|
61
|
-
/** 현재
|
|
74
|
+
/** 현재 액세스 토큰 (없으면 null) */
|
|
62
75
|
getToken(): string | null;
|
|
63
76
|
/** 토큰 저장 + 구독자 통지. refresh_token이 있으면 함께 회전 저장한다(없으면 기존 유지) */
|
|
64
77
|
setTokens(tokens: { access_token: string; refresh_token?: string }): void;
|
|
65
78
|
/**
|
|
66
|
-
* 무음 세션 갱신 — refresh 토큰으로 새
|
|
79
|
+
* 무음 세션 갱신 — refresh 토큰으로 새 액세스 토큰을 받아 회전 저장한다.
|
|
67
80
|
* - 탭 내부: 동시 다발 호출을 single-flight로 합친다.
|
|
68
81
|
* - 탭 간: navigator.locks(있으면)로 회전을 직렬화하고, 락 진입 시 다른 탭이 이미
|
|
69
82
|
* 회전했으면(세션 토큰 변경) 내 refresh 토큰을 소비하지 않고 true를 반환한다.
|
|
@@ -74,14 +87,14 @@ export interface StoreSession {
|
|
|
74
87
|
/** 세션/리프레시 키를 지우고 구독자에게 통지한다(서버의 토큰은 그대로다 — 로그아웃은 `logout()`) */
|
|
75
88
|
clear(): void;
|
|
76
89
|
/**
|
|
77
|
-
* 로그아웃 — 서버에서 **refresh 토큰(패밀리째)과 access 토큰을 폐기**(`
|
|
90
|
+
* 로그아웃 — 인가 서버에서 **refresh 토큰(패밀리째)과 access 토큰을 폐기**(`revokeUrl`, RFC 7009)한 뒤 세션을 지운다.
|
|
78
91
|
* refresh를 폐기하지 않으면 쿠키만 지워도 서버의 refresh 패밀리가 절대 만료까지 살아 있어, 새어 나간 refresh 토큰으로
|
|
79
|
-
* 계속 새
|
|
92
|
+
* 계속 새 액세스 토큰을 받을 수 있다. 폐기 요청 실패(네트워크·이미 무효)는 무시하고 세션은 항상 지운다.
|
|
80
93
|
*/
|
|
81
94
|
logout(): Promise<void>;
|
|
82
95
|
/** 변경 구독(useSyncExternalStore 소비용) — getToken을 스냅샷으로 쓴다. 해지 함수 반환 */
|
|
83
96
|
subscribe(cb: () => void): () => void;
|
|
84
|
-
/** 현재
|
|
97
|
+
/** 현재 액세스 토큰 (getToken()과 동일 — 게터 형태 소비용) */
|
|
85
98
|
readonly accessToken: string | null;
|
|
86
99
|
/** 현재 저장된 refresh 토큰 (없으면 null) */
|
|
87
100
|
readonly refreshToken: string | null;
|
|
@@ -101,7 +114,8 @@ export interface StoreSession {
|
|
|
101
114
|
}
|
|
102
115
|
|
|
103
116
|
export function createStoreSession(options: StoreSessionOptions): StoreSession {
|
|
104
|
-
const { baseUrl, clientId, storage } = options;
|
|
117
|
+
const { baseUrl, clientId, storage, authServer } = options;
|
|
118
|
+
const { revokeUrl, tokenUrl, resource } = authServer;
|
|
105
119
|
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
106
120
|
const listeners = new Set<() => void>();
|
|
107
121
|
|
|
@@ -127,7 +141,7 @@ export function createStoreSession(options: StoreSessionOptions): StoreSession {
|
|
|
127
141
|
|
|
128
142
|
/** RFC 7009 — 무효 토큰이어도 200이다. 실패는 호출부가 무시한다. 응답이 늦어도 로그아웃이 멈추지 않게 5초로 끊는다 */
|
|
129
143
|
async function revokeToken(token: string): Promise<void> {
|
|
130
|
-
await fetchFn(
|
|
144
|
+
await fetchFn(revokeUrl, {
|
|
131
145
|
method: "POST",
|
|
132
146
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
133
147
|
body: new URLSearchParams({ token, client_id: clientId }).toString(),
|
|
@@ -144,7 +158,7 @@ export function createStoreSession(options: StoreSessionOptions): StoreSession {
|
|
|
144
158
|
if (refreshInFlight) await refreshInFlight.catch(() => false);
|
|
145
159
|
const refreshToken = storage.get(STORE_SESSION_KEYS.refresh);
|
|
146
160
|
const accessToken = getToken();
|
|
147
|
-
// refresh 먼저 — 패밀리를 폐기해야 새
|
|
161
|
+
// refresh 먼저 — 패밀리를 폐기해야 새 액세스 토큰 발급이 끊긴다. access는 남은 수명 동안의 사용을 끊는다
|
|
148
162
|
for (const token of [refreshToken, accessToken]) {
|
|
149
163
|
if (!token) continue;
|
|
150
164
|
try {
|
|
@@ -180,7 +194,13 @@ export function createStoreSession(options: StoreSessionOptions): StoreSession {
|
|
|
180
194
|
const refreshToken = storage.get(STORE_SESSION_KEYS.refresh);
|
|
181
195
|
if (!refreshToken) return false;
|
|
182
196
|
try {
|
|
183
|
-
const tokens = await refreshUserToken({
|
|
197
|
+
const tokens = await refreshUserToken({
|
|
198
|
+
clientId,
|
|
199
|
+
refreshToken,
|
|
200
|
+
tokenUrl,
|
|
201
|
+
resource,
|
|
202
|
+
fetch: fetchFn,
|
|
203
|
+
});
|
|
184
204
|
setTokens({ access_token: tokens.access_token, refresh_token: tokens.refresh_token });
|
|
185
205
|
return true;
|
|
186
206
|
} catch {
|
|
@@ -218,20 +238,16 @@ export function createStoreSession(options: StoreSessionOptions): StoreSession {
|
|
|
218
238
|
const blob: PkceBlob = { verifier, state, redirectUri: authorizeOptions.redirectUri };
|
|
219
239
|
storage.set(STORE_SESSION_KEYS.pkce, JSON.stringify(blob));
|
|
220
240
|
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
);
|
|
231
|
-
if (authorizeOptions.screen === "signup") {
|
|
232
|
-
url.pathname = url.pathname.replace(/\/oauth2\/authorize$/, "/oauth2/signup");
|
|
233
|
-
}
|
|
234
|
-
const finalUrl = url.toString();
|
|
241
|
+
const finalUrl = buildAuthorizeUrl({
|
|
242
|
+
authorizeUrl: authServer.authorizeUrl,
|
|
243
|
+
clientId,
|
|
244
|
+
redirectUri: authorizeOptions.redirectUri,
|
|
245
|
+
state,
|
|
246
|
+
codeChallenge: challenge,
|
|
247
|
+
scope: authorizeOptions.scope,
|
|
248
|
+
resource,
|
|
249
|
+
prompt: authorizeOptions.prompt,
|
|
250
|
+
});
|
|
235
251
|
|
|
236
252
|
if (typeof window !== "undefined") {
|
|
237
253
|
window.location.assign(finalUrl);
|
|
@@ -249,11 +265,12 @@ export function createStoreSession(options: StoreSessionOptions): StoreSession {
|
|
|
249
265
|
throw new Error("state가 일치하지 않습니다. 다시 시도해주십시오");
|
|
250
266
|
}
|
|
251
267
|
const tokens = await exchangeAuthorizationCode({
|
|
252
|
-
baseUrl,
|
|
253
268
|
clientId,
|
|
254
269
|
redirectUri: saved.redirectUri,
|
|
255
270
|
code,
|
|
256
271
|
codeVerifier: saved.verifier,
|
|
272
|
+
tokenUrl,
|
|
273
|
+
resource,
|
|
257
274
|
fetch: fetchFn,
|
|
258
275
|
});
|
|
259
276
|
setTokens({ access_token: tokens.access_token, refresh_token: tokens.refresh_token });
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* 앱(OAuth 클라이언트) 관리 — `/v1/store/api-clients` (auth-spec 부록 B-13).
|
|
5
|
-
* 등록은 DCR(`POST /v1/oauth2/register`, RFC 7591)이 담당하고, 이 스키마는 등록 이후의
|
|
6
|
-
* 목록·상세·시크릿 재발급·비활성화 응답 계약이다. 시크릿 해시는 어떤 응답에도 포함되지 않는다.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
export const apiClientTypeSchema = z
|
|
10
|
-
.enum(["public", "confidential"])
|
|
11
|
-
.describe(
|
|
12
|
-
"클라이언트 유형. `public`=시크릿 없음(PKCE 로그인용), `confidential`=시크릿 보유(서버 간 연동용)",
|
|
13
|
-
);
|
|
14
|
-
export type ApiClientType = z.infer<typeof apiClientTypeSchema>;
|
|
15
|
-
|
|
16
|
-
export const apiClientStatusSchema = z
|
|
17
|
-
.enum(["ACTIVE", "REVOKED"])
|
|
18
|
-
.describe("상태. `REVOKED`는 비활성화된 클라이언트이며 다시 활성화할 수 없다");
|
|
19
|
-
export type ApiClientStatus = z.infer<typeof apiClientStatusSchema>;
|
|
20
|
-
|
|
21
|
-
export const apiClientSchema = z.object({
|
|
22
|
-
clientId: z.string().describe("클라이언트 ID (`client_id`)"),
|
|
23
|
-
name: z.string().describe("클라이언트 이름 (`client_name`)"),
|
|
24
|
-
type: apiClientTypeSchema,
|
|
25
|
-
status: apiClientStatusSchema,
|
|
26
|
-
redirectUris: z
|
|
27
|
-
.array(z.string())
|
|
28
|
-
.describe("authorization_code 리다이렉트 URI 허용 목록 (정확 일치)"),
|
|
29
|
-
scopes: z.array(z.string()).describe("클라이언트가 요청할 수 있는 최대 스코프"),
|
|
30
|
-
grantTypes: z.array(z.string()).describe("허용된 grant_type 목록"),
|
|
31
|
-
secretPrefix: z
|
|
32
|
-
.string()
|
|
33
|
-
.nullable()
|
|
34
|
-
.describe(
|
|
35
|
-
"시크릿 앞부분(식별용, 인증에 사용할 수 없음). public 클라이언트와 시크릿 앞부분이 기록되기 전에 등록된 클라이언트는 null",
|
|
36
|
-
),
|
|
37
|
-
createdBy: z
|
|
38
|
-
.string()
|
|
39
|
-
.nullable()
|
|
40
|
-
.describe("등록자 userId. 등록자 기록이 도입되기 전에 등록된 클라이언트는 null"),
|
|
41
|
-
createdByName: z.string().nullable().describe("등록자 이름 (표시용). 계정이 없으면 null"),
|
|
42
|
-
createdByEmail: z.string().nullable().describe("등록자 이메일 (표시용). 계정이 없으면 null"),
|
|
43
|
-
createdAt: z.string().describe("등록 일시 (ISO 8601)"),
|
|
44
|
-
revokedAt: z.string().nullable().describe("비활성화 일시 (ISO 8601). 활성 상태면 null"),
|
|
45
|
-
});
|
|
46
|
-
export type ApiClient = z.infer<typeof apiClientSchema>;
|
|
47
|
-
|
|
48
|
-
/** 시크릿 재발급 응답 — 평문 시크릿은 이 응답에서 단 한 번만 노출된다 */
|
|
49
|
-
export const apiClientSecretRotatedSchema = apiClientSchema.extend({
|
|
50
|
-
clientSecret: z
|
|
51
|
-
.string()
|
|
52
|
-
.describe("새 클라이언트 시크릿 평문. 이 응답에서만 노출되며 다시 조회할 수 없다"),
|
|
53
|
-
});
|
|
54
|
-
export type ApiClientSecretRotated = z.infer<typeof apiClientSecretRotatedSchema>;
|
|
55
|
-
|
|
56
|
-
export const apiClientListQuerySchema = z.object({
|
|
57
|
-
status: apiClientStatusSchema.optional().describe("상태 필터. 생략하면 전체"),
|
|
58
|
-
});
|
|
59
|
-
export type ApiClientListParams = z.infer<typeof apiClientListQuerySchema>;
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Dynamic Client Registration 요청 (RFC 7591 §2, auth-spec §12) — `POST /v1/oauth2/register`.
|
|
63
|
-
* OAuth 프로토콜 표면이라 필드명이 snake_case이고 응답·등록 오류는 평면 JSON이다.
|
|
64
|
-
* 생략한 필드는 서버 기본값을 따른다(SDK가 기본값을 채워 보내지 않는다).
|
|
65
|
-
*/
|
|
66
|
-
export const oauthClientRegistrationRequestSchema = z.object({
|
|
67
|
-
client_name: z.string().min(1).max(80).describe("클라이언트 이름 (1~80자)"),
|
|
68
|
-
token_endpoint_auth_method: z
|
|
69
|
-
.enum(["none", "client_secret_basic"])
|
|
70
|
-
.optional()
|
|
71
|
-
.describe(
|
|
72
|
-
"토큰 엔드포인트 인증 방식. `none`=public(시크릿 없음), `client_secret_basic`=confidential. 생략하면 `client_secret_basic`",
|
|
73
|
-
),
|
|
74
|
-
redirect_uris: z
|
|
75
|
-
.array(z.string())
|
|
76
|
-
.max(10)
|
|
77
|
-
.optional()
|
|
78
|
-
.describe(
|
|
79
|
-
"리다이렉트 URI 목록(최대 10개). authorization_code에는 필수이며 https(개발용 루프백 http 제외)만 허용",
|
|
80
|
-
),
|
|
81
|
-
grant_types: z
|
|
82
|
-
.array(
|
|
83
|
-
z.enum([
|
|
84
|
-
"authorization_code",
|
|
85
|
-
"refresh_token",
|
|
86
|
-
"client_credentials",
|
|
87
|
-
"urn:ietf:params:oauth:grant-type:token-exchange",
|
|
88
|
-
]),
|
|
89
|
-
)
|
|
90
|
-
.min(1)
|
|
91
|
-
.optional()
|
|
92
|
-
.describe("허용할 grant_type 목록. 생략하면 `client_credentials`"),
|
|
93
|
-
scope: z
|
|
94
|
-
.string()
|
|
95
|
-
.optional()
|
|
96
|
-
.describe(
|
|
97
|
-
"공백 구분 스코프. 등록자 보유 스코프 이내여야 한다. 생략하면 confidential은 등록자 스코프, public은 `stores:read`",
|
|
98
|
-
),
|
|
99
|
-
});
|
|
100
|
-
export type OAuthClientRegistrationRequest = z.infer<typeof oauthClientRegistrationRequestSchema>;
|
|
101
|
-
|
|
102
|
-
/** Dynamic Client Registration 응답 (RFC 7591 §3.2.1) — client_secret은 이 응답에서만 노출된다 */
|
|
103
|
-
export const oauthClientRegistrationResponseSchema = z.object({
|
|
104
|
-
client_id: z.string().describe("발급된 클라이언트 ID"),
|
|
105
|
-
client_secret: z
|
|
106
|
-
.string()
|
|
107
|
-
.optional()
|
|
108
|
-
.describe("클라이언트 시크릿 평문. confidential만 있으며 이 응답에서만 노출된다"),
|
|
109
|
-
client_secret_expires_at: z
|
|
110
|
-
.number()
|
|
111
|
-
.int()
|
|
112
|
-
.optional()
|
|
113
|
-
.describe("시크릿 만료 시각(epoch 초). 0은 만료 없음. confidential만 있다"),
|
|
114
|
-
client_id_issued_at: z.number().int().describe("발급 시각 (epoch 초)"),
|
|
115
|
-
client_name: z.string(),
|
|
116
|
-
token_endpoint_auth_method: z.enum(["none", "client_secret_basic"]),
|
|
117
|
-
redirect_uris: z.array(z.string()),
|
|
118
|
-
grant_types: z.array(z.string()),
|
|
119
|
-
scope: z.string().describe("부여된 스코프 (공백 구분)"),
|
|
120
|
-
});
|
|
121
|
-
export type OAuthClientRegistrationResponse = z.infer<typeof oauthClientRegistrationResponseSchema>;
|