@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/src/session.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  import { type MyAccount, myAccountSchema } from "./schemas/auth";
10
10
 
11
11
  /**
12
- * SDK 세션 매니저 — 콘솔(userToken)의 저장·무음 갱신·회전을 캡슐화한다.
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 (/oauth2/token이 덧붙는다) */
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
- /** 생략 시 서버 기본(stores:read). refresh가 필요하면 offline_access 포함 */
67
+ /** 생략 시 발급자 기본. refresh가 필요하면 offline_access 포함 */
55
68
  scope?: string;
56
- /** "signup"이면 authorize 대신 호스티드 회원가입 화면으로 진입한다 */
57
- screen?: "authorize" | "signup";
69
+ /** OIDC `prompt` — `login`(재인증)·`create`(가입)·`consent` */
70
+ prompt?: "login" | "create" | "consent";
58
71
  }
59
72
 
60
73
  export interface StoreSession {
61
- /** 현재 userToken (없으면 null) */
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 토큰으로 새 userToken을 받아 회전 저장한다.
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 토큰을 폐기**(`POST /oauth2/revoke`, RFC 7009)한 뒤 세션을 지운다.
90
+ * 로그아웃 — 인가 서버에서 **refresh 토큰(패밀리째)과 access 토큰을 폐기**(`revokeUrl`, RFC 7009)한 뒤 세션을 지운다.
78
91
  * refresh를 폐기하지 않으면 쿠키만 지워도 서버의 refresh 패밀리가 절대 만료까지 살아 있어, 새어 나간 refresh 토큰으로
79
- * 계속 새 userToken을 받을 수 있다. 폐기 요청 실패(네트워크·이미 무효)는 무시하고 세션은 항상 지운다.
92
+ * 계속 새 액세스 토큰을 받을 수 있다. 폐기 요청 실패(네트워크·이미 무효)는 무시하고 세션은 항상 지운다.
80
93
  */
81
94
  logout(): Promise<void>;
82
95
  /** 변경 구독(useSyncExternalStore 소비용) — getToken을 스냅샷으로 쓴다. 해지 함수 반환 */
83
96
  subscribe(cb: () => void): () => void;
84
- /** 현재 userToken (getToken()과 동일 — 게터 형태 소비용) */
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(`${baseUrl}/oauth2/revoke`, {
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 먼저 — 패밀리를 폐기해야 새 userToken 발급이 끊긴다. access는 남은 수명 동안의 사용을 끊는다
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({ baseUrl, clientId, refreshToken, fetch: fetchFn });
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 url = new URL(
222
- buildAuthorizeUrl({
223
- baseUrl,
224
- clientId,
225
- redirectUri: authorizeOptions.redirectUri,
226
- state,
227
- codeChallenge: challenge,
228
- scope: authorizeOptions.scope,
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>;