@infoteam/account-web 0.0.0-stage → 0.6.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GSA Infoteam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,78 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # @infoteam/account-web
2
+
3
+ 인포팀 계정(IdP) 로그인을 웹 앱에 붙인다. 특정 프레임워크에 의존하지 않는다. React 앱이라면 [`@infoteam/account-react`](../react)를 쓴다.
4
+
5
+ ## 설치
6
+
7
+ ```bash
8
+ pnpm add @infoteam/account-web zod
9
+ ```
10
+
11
+ ## 사용
12
+
13
+ ```ts
14
+ import { createAccountClient } from '@infoteam/account-web';
15
+
16
+ // 라우터보다 먼저 만든다. callback 으로 열린 페이지면 여기서 주소를 로그인 전 위치로 바꾼다
17
+ const account = createAccountClient({ issuer: 'production', clientId, api: apiUrl });
18
+
19
+ await account.ready(); // callback 이면 백엔드 로그인을 마치고, 아니면 세션을 복구한다
20
+ account.store.subscribe((state) => render(state));
21
+
22
+ // 로그인 버튼
23
+ await account.login({ returnTo: location.pathname });
24
+
25
+ // 라우터 guard
26
+ await account.requireAuth({ returnTo: nextUrl });
27
+
28
+ // API 요청. 401 이면 토큰을 갱신한 뒤 다시 보낸다
29
+ await account.fetch(`${apiUrl}/me`);
30
+ ```
31
+
32
+ - 상태는 zustand의 vanilla store(`account.store`)에 있으므로, 어느 프레임워크에서든 구독할 수 있다.
33
+ - API 요청에는 `account.fetch`, `@infoteam/account-web/openapi-fetch`, `@infoteam/account-web/axios` 중 하나를 쓴다.
34
+ - 옵션, guard, 에러 코드, 로그아웃은 React와 같다. [`account-react` README](../react/README.md#사용)를 참고한다.
35
+
36
+ ## 백엔드 형식이 다를 때
37
+
38
+ `api`는 백엔드가 [세션 프로토콜](../../README.md#세션-프로토콜)을 구현했다고 가정한다. 형식이 다르면 `api` 대신 `session`에 백엔드 호출을 직접
39
+ 구현해서 넘긴다. 둘 중 하나만 줄 수 있다.
40
+
41
+ ```ts
42
+ import { createAccountClient, type Session } from '@infoteam/account-web';
43
+
44
+ const session: Session = {
45
+ async start(credential) {
46
+ if ('ticket' in credential) throw new Error('this backend does not reject logins');
47
+ const res = await fetch(`${apiUrl}/oauth/callback`, {
48
+ method: 'POST',
49
+ credentials: 'include',
50
+ headers: { 'content-type': 'application/json' },
51
+ body: JSON.stringify({
52
+ code: credential.code,
53
+ verifier: credential.codeVerifier,
54
+ redirectUri: credential.redirectUri,
55
+ nonce: credential.nonce,
56
+ }),
57
+ });
58
+ if (!res.ok) throw new Error(`login failed: ${res.status}`);
59
+ const { token } = await res.json();
60
+ return { tokens: { accessToken: token } };
61
+ },
62
+ async refresh() {
63
+ const res = await fetch(`${apiUrl}/token`, { method: 'POST', credentials: 'include' });
64
+ if (res.status === 401) return null; // 세션 없음
65
+ if (!res.ok) throw new Error(`refresh failed: ${res.status}`);
66
+ return { accessToken: (await res.json()).token };
67
+ },
68
+ async end() {
69
+ await fetch(`${apiUrl}/signout`, { method: 'POST', credentials: 'include' });
70
+ },
71
+ };
72
+
73
+ const account = createAccountClient({ issuer: 'production', clientId, session });
74
+ ```
75
+
76
+ - `start`는 callback으로 받은 code, PKCE verifier, `redirectUri`, `nonce`를 받는다. 백엔드는 code를 교환할 때 이 `redirectUri`를 그대로 쓰고, id_token의 `nonce`를 확인해야 한다.
77
+ - 로그인 전에 추가 입력을 받으려면 `start`가 `{ rejected: { error, ticket } }`를 돌려준다. 그러면 `retryLogin(body)`가 `start({ ticket }, body)`로 다시 부른다.
78
+ - `refresh`는 세션이 없으면 `null`을 돌려준다. 다른 실패는 throw 한다.
@@ -0,0 +1,6 @@
1
+ import { t as AccountClient } from "./create-account-client-MB6AawN9.js";
2
+ import { AxiosInstance } from "axios";
3
+ //#region src/adapters/axios.d.ts
4
+ /** 401이면 refresh 한 뒤 한 번 다시 시도한다. 돌려주는 함수를 부르면 interceptor를 제거한다. */
5
+ export declare function attachAccount(instance: AxiosInstance, client: AccountClient): () => void;
6
+ //#endregion
package/dist/axios.js ADDED
@@ -0,0 +1,28 @@
1
+ import { AUTHORIZATION_HEADER, bearer, isUnauthorized, readBearer } from "@infoteam/account-core";
2
+ //#region src/adapters/axios.ts
3
+ /** 401이면 refresh 한 뒤 한 번 다시 시도한다. 돌려주는 함수를 부르면 interceptor를 제거한다. */
4
+ function attachAccount(instance, client) {
5
+ const retried = /* @__PURE__ */ new WeakSet();
6
+ const request = instance.interceptors.request.use(async (config) => {
7
+ await client.ready();
8
+ const token = client.getAccessToken();
9
+ if (token) config.headers.set(AUTHORIZATION_HEADER, bearer(token));
10
+ return config;
11
+ });
12
+ const response = instance.interceptors.response.use(void 0, async (error) => {
13
+ const config = error.config;
14
+ if (!isUnauthorized(error.response?.status) || !config || retried.has(config)) throw error;
15
+ const stale = readBearer(String(config.headers.get(AUTHORIZATION_HEADER) ?? ""));
16
+ const token = await client.refresh(stale);
17
+ if (!token) throw error;
18
+ retried.add(config);
19
+ config.headers.set(AUTHORIZATION_HEADER, bearer(token));
20
+ return instance.request(config);
21
+ });
22
+ return () => {
23
+ instance.interceptors.request.eject(request);
24
+ instance.interceptors.response.eject(response);
25
+ };
26
+ }
27
+ //#endregion
28
+ export { attachAccount };
@@ -0,0 +1,139 @@
1
+ import { AccountError, Fetch, IssuerInput, Prompt } from "@infoteam/account-core";
2
+ import * as z from "zod/mini";
3
+ import { StoreApi } from "zustand/vanilla";
4
+ //#region src/client/account-state.d.ts
5
+ /** client가 세션 복구를 시도하기 전까지는 `loading`이다. */
6
+ type AccountStatus = 'loading' | 'authenticated' | 'unauthenticated';
7
+ interface AccountState {
8
+ status: AccountStatus;
9
+ /** 메모리에만 두고, 새로고침하면 세션 refresh로 복구한다. */
10
+ accessToken: string | null;
11
+ error: AccountError | null;
12
+ canRetryLogin: boolean;
13
+ }
14
+ //#endregion
15
+ //#region src/session/session-adapter.d.ts
16
+ type LoginCredential = {
17
+ code: string;
18
+ codeVerifier: string;
19
+ redirectUri: string;
20
+ nonce: string;
21
+ iss: string | undefined;
22
+ } | {
23
+ ticket: string;
24
+ };
25
+ interface SessionTokens {
26
+ accessToken: string;
27
+ expiresIn?: number | undefined;
28
+ }
29
+ /** code는 한 번만 교환할 수 있어서, 재시도할 때는 백엔드가 이미 확인한 사용자를 담은 ticket을 쓴다. */
30
+ interface SessionRejection {
31
+ error: AccountError;
32
+ ticket: string | undefined;
33
+ }
34
+ interface SessionPaths {
35
+ login?: string;
36
+ refresh?: string;
37
+ logout?: string;
38
+ }
39
+ interface Session {
40
+ start(credential: LoginCredential, body?: unknown): Promise<{
41
+ tokens: SessionTokens;
42
+ } | {
43
+ rejected: SessionRejection;
44
+ }>;
45
+ refresh(): Promise<SessionTokens | null>;
46
+ end(accessToken: string | null): Promise<void>;
47
+ }
48
+ //#endregion
49
+ //#region src/client/options.d.ts
50
+ declare const AccountClientOptionsSchema: z.ZodMiniObject<{
51
+ issuer: z.ZodMiniCustom<IssuerInput, IssuerInput>;
52
+ clientId: z.ZodMiniString<string>;
53
+ api: z.ZodMiniOptional<z.ZodMiniUnion<readonly [z.ZodMiniString<string>, z.ZodMiniObject<{
54
+ baseUrl: z.ZodMiniString<string>;
55
+ paths: z.ZodMiniOptional<z.ZodMiniObject<{
56
+ login: z.ZodMiniOptional<z.ZodMiniString<string>>;
57
+ refresh: z.ZodMiniOptional<z.ZodMiniString<string>>;
58
+ logout: z.ZodMiniOptional<z.ZodMiniString<string>>;
59
+ }, z.core.$strip>>;
60
+ }, z.core.$strip>]>>;
61
+ session: z.ZodMiniOptional<z.ZodMiniCustom<Session, Session>>;
62
+ redirectUri: z.ZodMiniOptional<z.ZodMiniString<string>>;
63
+ scopes: z.ZodMiniOptional<z.ZodMiniArray<z.ZodMiniEnum<{
64
+ email: "email";
65
+ profile: "profile";
66
+ name: "name";
67
+ picture: "picture";
68
+ phone_number: "phone_number";
69
+ student_id: "student_id";
70
+ }>>>;
71
+ silentLogin: z.ZodMiniOptional<z.ZodMiniBoolean<boolean>>;
72
+ fetch: z.ZodMiniOptional<z.ZodMiniCustom<typeof fetch, typeof fetch>>;
73
+ transactionStorage: z.ZodMiniOptional<z.ZodMiniCustom<Storage, Storage>>;
74
+ redirect: z.ZodMiniOptional<z.ZodMiniCustom<(url: string) => void, (url: string) => void>>;
75
+ }, z.core.$strip>;
76
+ type AccountClientOptions = z.input<typeof AccountClientOptionsSchema>;
77
+ //#endregion
78
+ //#region src/client/create-account-client.d.ts
79
+ interface LoginOptions {
80
+ /** 다른 origin을 가리키면 버린다. */
81
+ returnTo?: string;
82
+ prompt?: Prompt;
83
+ }
84
+ interface LogoutOptions {
85
+ /** IdP 로그인 세션도 끝낸다. 공용 PC처럼 다음 사람이 같은 IdP 계정으로 자동 로그인되면 안 될 때 쓴다. */
86
+ idp?: boolean;
87
+ /** `idp: true`일 때만 쓴다. 같은 origin이어야 한다. */
88
+ returnTo?: string;
89
+ }
90
+ interface RequireAuthOptions {
91
+ /**
92
+ * 생략하면 현재 페이지를 쓰는데, 라우터 guard에서는 현재 페이지가 아직 이전 페이지다. 이동하려는 대상을 넘긴다.
93
+ * TanStack Router에서는 `location.href`, React Router에서는 `request.url`이 그 대상이다.
94
+ */
95
+ returnTo?: string;
96
+ }
97
+ interface AccountClient {
98
+ readonly store: StoreApi<AccountState>;
99
+ getAccessToken(): string | null;
100
+ /** 여러 번 불러도 초기화는 한 번만 한다. 실패해도 에러를 던지지 않고 `store`의 `error`에 남긴다. */
101
+ ready(): Promise<void>;
102
+ /** callback을 처리하거나 자동 로그인을 시도하는 중인지 알려 준다. 곧 떠날 화면이 깜빡이지 않게 가릴 때 쓴다. */
103
+ isHandlingRedirect(): boolean;
104
+ /**
105
+ * 세션이 없으면 사용자를 IdP로 보내고 끝나지 않는 Promise를 돌려줘서, 라우터가 이동 중 화면을 유지하게 한다.
106
+ *
107
+ * @throws {@link AccountError} 이 페이지의 callback이 실패했으면 그 에러를 던진다. 다시 IdP로 보내면 취소한 사용자가
108
+ * 빠져나갈 수 없게 되므로, 라우터의 에러 화면에서 `error.code`로 경우를 나눈다.
109
+ */
110
+ requireAuth(options?: RequireAuthOptions): Promise<void>;
111
+ /**
112
+ * `logout({ idp: false })`는 IdP 로그인 세션을 남기므로, 로그아웃 뒤 첫 로그인에는 `prompt=login`을 붙여
113
+ * 사용자가 계정을 바꿀 수 있게 한다.
114
+ */
115
+ login(options?: LoginOptions): Promise<void>;
116
+ /**
117
+ * `session_rejected`로 거절된 로그인을 body를 더해 다시 보낸다. 거절 응답의 ticket을 쓰므로 IdP를 다시
118
+ * 거치지 않는다.
119
+ */
120
+ retryLogin(body: unknown): Promise<void>;
121
+ /**
122
+ * 어차피 로컬에서는 로그아웃되므로 서버 쪽 실패는 조용히 넘긴다. IdP가 로그아웃을 지원하지 않으면 `idp: true`여도
123
+ * 서비스 세션만 끝낸다.
124
+ */
125
+ logout(options?: LogoutOptions): Promise<void>;
126
+ /** `staleToken`을 다른 호출이 이미 교체했다면 다시 refresh 하지 않고 그 새 토큰을 돌려준다. */
127
+ refresh(staleToken?: string | null): Promise<string | null>;
128
+ /** access token을 보내고, 401이면 refresh 한 뒤 한 번 다시 시도하는 `fetch`다. */
129
+ fetch: Fetch;
130
+ }
131
+ /**
132
+ * client를 라우터보다 먼저 만들어야 한다. 페이지가 callback으로 열렸으면 만드는 즉시 주소를 로그인 전 위치로 바꾸는데,
133
+ * 라우터는 만들어질 때 주소를 읽기 때문이다.
134
+ *
135
+ * @throws {@link AccountError} 옵션이 없거나 비어 있으면 `invalid_request`를 던진다.
136
+ */
137
+ declare function createAccountClient(input: AccountClientOptions): AccountClient;
138
+ //#endregion
139
+ export { createAccountClient as a, LoginCredential as c, SessionRejection as d, SessionTokens as f, RequireAuthOptions as i, Session as l, AccountStatus as m, LoginOptions as n, AccountClientOptions as o, AccountState as p, LogoutOptions as r, AccountClientOptionsSchema as s, AccountClient as t, SessionPaths as u };
@@ -0,0 +1,3 @@
1
+ import { a as createAccountClient, c as LoginCredential, d as SessionRejection, f as SessionTokens, i as RequireAuthOptions, l as Session, m as AccountStatus, n as LoginOptions, o as AccountClientOptions, p as AccountState, r as LogoutOptions, s as AccountClientOptionsSchema, t as AccountClient, u as SessionPaths } from "./create-account-client-MB6AawN9.js";
2
+ import { AccountError, AccountErrorCode, ClientScope, Environment, IssuerInput, Prompt } from "@infoteam/account-core";
3
+ export { type AccountClient, type AccountClientOptions, AccountClientOptionsSchema, AccountError, type AccountErrorCode, type AccountState, type AccountStatus, type ClientScope, type Environment, type IssuerInput, type LoginCredential, type LoginOptions, type LogoutOptions, type Prompt, type RequireAuthOptions, type Session, type SessionPaths, type SessionRejection, type SessionTokens, createAccountClient };
package/dist/index.js ADDED
@@ -0,0 +1,447 @@
1
+ import { AUTHORIZATION_CODE_TTL_MS, AUTHORIZATION_HEADER, AccountError, AccountError as AccountError$1, CALLBACK_PATH, ClientScopeSchema, OPENID_SCOPE, SESSION_PATHS, SESSION_REJECTED, STORAGE_NAMESPACE, ScopeSchema, SessionTokensResponseSchema, bearer, createAuthorization, createLogoutUrl, discover, fetchClientRegistration, isAuthFailure, isCallbackUrl, isUnauthorized, parseCallback, readCallbackState } from "@infoteam/account-core";
2
+ import { Mutex, attempt, attemptAsync, isFunction, isPlainObject, isString, trimEnd } from "es-toolkit";
3
+ import * as z from "zod/mini";
4
+ import { createStore } from "zustand/vanilla";
5
+ //#region src/session/session-adapter.ts
6
+ const SessionTokensSchema = z.pipe(SessionTokensResponseSchema, z.transform((raw) => ({
7
+ accessToken: raw.access_token,
8
+ expiresIn: raw.expires_in
9
+ })));
10
+ //#endregion
11
+ //#region src/client/options.ts
12
+ const nonEmpty = () => z.string().check(z.minLength(1));
13
+ const SessionPathsSchema = z.object({
14
+ login: z.optional(nonEmpty()),
15
+ refresh: z.optional(nonEmpty()),
16
+ logout: z.optional(nonEmpty())
17
+ });
18
+ const isSession = (value) => typeof value === "object" && value !== null && [
19
+ "start",
20
+ "refresh",
21
+ "end"
22
+ ].every((key) => isFunction(Reflect.get(value, key)));
23
+ const AccountClientOptionsSchema = z.object({
24
+ /** `'production'`, `'staging'`, 또는 다른 account-be 배포의 `https:` API origin이다. */
25
+ issuer: z.custom((value) => isString(value) && value.length > 0),
26
+ clientId: nonEmpty(),
27
+ /**
28
+ * [세션 프로토콜](../../README.md#세션-프로토콜)을 구현한 백엔드의 origin이나 `/api` 같은 기본 경로다. endpoint 경로가
29
+ * 다르면 `{ baseUrl, paths }`로 준다. `session`과 함께 줄 수 없다.
30
+ */
31
+ api: z.optional(z.union([nonEmpty(), z.object({
32
+ baseUrl: nonEmpty(),
33
+ paths: z.optional(SessionPathsSchema)
34
+ })])),
35
+ /** 백엔드가 세션 프로토콜과 다른 형식이면 백엔드 호출을 직접 구현해서 준다. `api`와 함께 줄 수 없다. */
36
+ session: z.optional(z.custom(isSession)),
37
+ /** 기본값은 `/auth/callback`이다. client가 이 URL을 라우터보다 먼저 처리하므로 라우트를 만들지 않는다. */
38
+ redirectUri: z.optional(nonEmpty()),
39
+ /**
40
+ * 생략하면 IdP에 등록한 필수 scope와 선택 scope를 모두 요청한다. `openid`는 서비스 백엔드가 id_token으로
41
+ * 사용자를 확인하므로 항상 함께 요청한다.
42
+ */
43
+ scopes: z.optional(z.array(ClientScopeSchema)),
44
+ /**
45
+ * 세션이 없으면 IdP에 `prompt=none`으로 한 번 다녀온다. 탭마다 한 번만 시도하고, 로그아웃한 뒤에는 시도하지
46
+ * 않는다.
47
+ */
48
+ silentLogin: z.optional(z.boolean()),
49
+ fetch: z.optional(z.custom(isFunction)),
50
+ transactionStorage: z.optional(z.custom()),
51
+ redirect: z.optional(z.custom(isFunction))
52
+ }).check(z.refine((options) => options.api === void 0 !== (options.session === void 0), { message: "give exactly one of api or session" }));
53
+ //#endregion
54
+ //#region src/session/request.ts
55
+ function createRequester(baseUrl, fetch) {
56
+ const base = trimEnd(baseUrl, "/");
57
+ return async ({ path, accessToken, body }) => {
58
+ const headers = new Headers({ accept: "application/json" });
59
+ if (accessToken) headers.set(AUTHORIZATION_HEADER, bearer(accessToken));
60
+ if (body !== void 0) headers.set("content-type", "application/json");
61
+ const [error, response] = await attemptAsync(() => (fetch ?? globalThis.fetch)(`${base}${path}`, {
62
+ method: "POST",
63
+ headers,
64
+ body: body === void 0 ? void 0 : JSON.stringify(body),
65
+ credentials: "include"
66
+ }));
67
+ if (!response) throw new AccountError$1("network_error", {
68
+ description: `POST ${path}`,
69
+ cause: error
70
+ });
71
+ return response;
72
+ };
73
+ }
74
+ async function readTokens(response, path) {
75
+ const [, body] = await attemptAsync(() => response.json());
76
+ if (!response.ok) throw new AccountError$1(response.status >= 500 ? "server_error" : "unknown", {
77
+ description: `POST ${path}`,
78
+ status: response.status,
79
+ details: body
80
+ });
81
+ const parsed = SessionTokensSchema.safeParse(body);
82
+ if (!parsed.success) throw new AccountError$1("server_error", {
83
+ description: `POST ${path}: ${z.prettifyError(parsed.error)}`,
84
+ cause: parsed.error
85
+ });
86
+ return parsed.data;
87
+ }
88
+ //#endregion
89
+ //#region src/session/api-session.ts
90
+ const RejectionSchema = z.looseObject({
91
+ ticket: z.optional(z.string()),
92
+ details: z.optional(z.unknown())
93
+ });
94
+ function apiSession(baseUrl, paths, fetch) {
95
+ const request = createRequester(baseUrl, fetch);
96
+ const { login, refresh, logout } = {
97
+ ...SESSION_PATHS,
98
+ ...paths
99
+ };
100
+ return {
101
+ async start(credential, body) {
102
+ const extra = isPlainObject(body) ? body : {};
103
+ const protocol = "ticket" in credential ? { ticket: credential.ticket } : {
104
+ code: credential.code,
105
+ code_verifier: credential.codeVerifier,
106
+ redirect_uri: credential.redirectUri,
107
+ nonce: credential.nonce,
108
+ iss: credential.iss
109
+ };
110
+ const response = await request({
111
+ path: login,
112
+ body: {
113
+ ...extra,
114
+ ...protocol,
115
+ transport: "cookie"
116
+ }
117
+ });
118
+ if (response.status !== 403) return { tokens: await readTokens(response, login) };
119
+ const [, raw] = await attemptAsync(() => response.json());
120
+ const rejection = RejectionSchema.safeParse(raw);
121
+ return { rejected: {
122
+ error: new AccountError$1(SESSION_REJECTED, {
123
+ description: `POST ${login}`,
124
+ status: response.status,
125
+ details: rejection.success ? rejection.data.details : raw
126
+ }),
127
+ ticket: rejection.success ? rejection.data.ticket : void 0
128
+ } };
129
+ },
130
+ async refresh() {
131
+ const response = await request({ path: refresh });
132
+ if (isAuthFailure(response.status)) return null;
133
+ return readTokens(response, refresh);
134
+ },
135
+ async end(accessToken) {
136
+ await request({
137
+ path: logout,
138
+ accessToken: accessToken ?? void 0
139
+ });
140
+ }
141
+ };
142
+ }
143
+ //#endregion
144
+ //#region src/storage/keys.ts
145
+ const STORAGE_KEYS = {
146
+ transactionPrefix: `${STORAGE_NAMESPACE}:tx:`,
147
+ loggedOut: `${STORAGE_NAMESPACE}:logged-out`,
148
+ silentLoginTried: `${STORAGE_NAMESPACE}:silent-login-tried`,
149
+ logoutPrefix: `${STORAGE_NAMESPACE}:logout:`
150
+ };
151
+ //#endregion
152
+ //#region src/storage/transactions.ts
153
+ const PREFIX = STORAGE_KEYS.transactionPrefix;
154
+ const StoredTransactionSchema = z.object({
155
+ state: z.string(),
156
+ codeVerifier: z.string(),
157
+ nonce: z.optional(z.string()),
158
+ redirectUri: z.string(),
159
+ scopes: z.array(ScopeSchema),
160
+ createdAt: z.number(),
161
+ returnTo: z.optional(z.string()),
162
+ silent: z.optional(z.boolean())
163
+ });
164
+ /** `state`를 키로 저장해야 여러 탭에서 동시에 로그인을 시작해도 서로의 verifier를 덮어쓰지 않는다. */
165
+ function saveTransaction(storage, transaction, extra = {}) {
166
+ pruneExpired(storage);
167
+ const stored = {
168
+ ...transaction,
169
+ ...extra
170
+ };
171
+ storage.setItem(PREFIX + transaction.state, JSON.stringify(stored));
172
+ }
173
+ function takeTransaction(storage, state) {
174
+ if (!state) return null;
175
+ const raw = storage.getItem(PREFIX + state);
176
+ storage.removeItem(PREFIX + state);
177
+ if (!raw) return null;
178
+ const parsed = StoredTransactionSchema.safeParse(safeJson(raw));
179
+ if (!parsed.success || parsed.data.state !== state || isExpired(parsed.data)) return null;
180
+ return parsed.data;
181
+ }
182
+ function pruneExpired(storage) {
183
+ for (let i = storage.length - 1; i >= 0; i--) {
184
+ const key = storage.key(i);
185
+ if (!key?.startsWith(PREFIX)) continue;
186
+ const parsed = StoredTransactionSchema.safeParse(safeJson(storage.getItem(key)));
187
+ if (!parsed.success || isExpired(parsed.data)) storage.removeItem(key);
188
+ }
189
+ }
190
+ function isExpired(transaction) {
191
+ return Date.now() - transaction.createdAt > AUTHORIZATION_CODE_TTL_MS;
192
+ }
193
+ function safeJson(raw) {
194
+ if (raw === null) return void 0;
195
+ const [, value] = attempt(() => JSON.parse(raw));
196
+ return value;
197
+ }
198
+ //#endregion
199
+ //#region src/client/create-account-client.ts
200
+ /**
201
+ * client를 라우터보다 먼저 만들어야 한다. 페이지가 callback으로 열렸으면 만드는 즉시 주소를 로그인 전 위치로 바꾸는데,
202
+ * 라우터는 만들어질 때 주소를 읽기 때문이다.
203
+ *
204
+ * @throws {@link AccountError} 옵션이 없거나 비어 있으면 `invalid_request`를 던진다.
205
+ */
206
+ function createAccountClient(input) {
207
+ const parsed = AccountClientOptionsSchema.safeParse(input);
208
+ if (!parsed.success) throw new AccountError$1("invalid_request", { description: `createAccountClient options: ${z.prettifyError(parsed.error)}` });
209
+ const options = parsed.data;
210
+ const { clientId, fetch } = options;
211
+ const session = options.session ?? sessionFromApi(options.api, fetch);
212
+ const transactions = options.transactionStorage ?? globalThis.sessionStorage;
213
+ const redirect = options.redirect ?? ((url) => window.location.assign(url));
214
+ const metadata = () => discover(options.issuer, { fetch });
215
+ const redirectUri = () => new URL(options.redirectUri ?? CALLBACK_PATH, window.location.origin).toString();
216
+ let registeredScopes;
217
+ const scopes = async () => {
218
+ if (options.scopes) return options.scopes;
219
+ registeredScopes ??= metadata().then((current) => fetchClientRegistration(current, clientId, { fetch })).then(({ scopes: required, optionalScopes }) => [...required, ...optionalScopes]);
220
+ return registeredScopes.catch((error) => {
221
+ registeredScopes = void 0;
222
+ throw error;
223
+ });
224
+ };
225
+ const store = createStore(() => ({
226
+ status: "loading",
227
+ accessToken: null,
228
+ error: null,
229
+ canRetryLogin: false
230
+ }));
231
+ const refreshMutex = new Mutex();
232
+ const callback = takeCallback(redirectUri(), transactions);
233
+ const trySilentLogin = options.silentLogin === true && callback === null && transactions.getItem(STORAGE_KEYS.silentLoginTried) === null && localStorage.getItem(STORAGE_KEYS.loggedOut) === null;
234
+ let callbackError = null;
235
+ let rejection = null;
236
+ let initializing;
237
+ let settled = false;
238
+ const authenticate = (accessToken) => {
239
+ rejection = null;
240
+ callbackError = null;
241
+ store.setState({
242
+ status: "authenticated",
243
+ accessToken,
244
+ error: null,
245
+ canRetryLogin: false
246
+ });
247
+ };
248
+ const unauthenticate = (error = null) => {
249
+ store.setState({
250
+ status: "unauthenticated",
251
+ accessToken: null,
252
+ error,
253
+ canRetryLogin: rejection?.ticket !== void 0
254
+ });
255
+ };
256
+ const startSession = async (credential, body) => {
257
+ const result = await session.start(credential, body);
258
+ if ("rejected" in result) {
259
+ rejection = result.rejected;
260
+ throw result.rejected.error;
261
+ }
262
+ localStorage.removeItem(STORAGE_KEYS.loggedOut);
263
+ authenticate(result.tokens.accessToken);
264
+ };
265
+ const finishCallback = async ({ url, transaction }) => {
266
+ try {
267
+ const params = parseCallback(await metadata(), url, {
268
+ clientId,
269
+ state: transaction?.state
270
+ });
271
+ if (!transaction?.nonce) throw new AccountError$1("state_mismatch");
272
+ const { codeVerifier, redirectUri: sentRedirectUri, nonce } = transaction;
273
+ await startSession({
274
+ code: params.code,
275
+ codeVerifier,
276
+ redirectUri: sentRedirectUri,
277
+ nonce,
278
+ iss: params.iss
279
+ });
280
+ } catch (error) {
281
+ const accountError = toAccountError(error);
282
+ if (transaction?.silent && SILENT_LOGIN_FAILURES.has(accountError.code)) {
283
+ unauthenticate();
284
+ return;
285
+ }
286
+ callbackError = accountError;
287
+ unauthenticate(callbackError);
288
+ }
289
+ };
290
+ const authorize = async (returnTo, prompt, silent) => {
291
+ const loggedOut = localStorage.getItem(STORAGE_KEYS.loggedOut) !== null;
292
+ const { url, transaction } = await createAuthorization(await metadata(), {
293
+ clientId,
294
+ redirectUri: redirectUri(),
295
+ scopes: [OPENID_SCOPE, ...await scopes()],
296
+ prompt: prompt ?? (loggedOut ? "login" : void 0)
297
+ });
298
+ saveTransaction(transactions, transaction, {
299
+ returnTo: sameOriginPath(returnTo),
300
+ silent
301
+ });
302
+ redirect(url);
303
+ };
304
+ const restoreSession = async () => {
305
+ try {
306
+ const tokens = await session.refresh();
307
+ if (tokens) return authenticate(tokens.accessToken);
308
+ if (trySilentLogin) {
309
+ transactions.setItem(STORAGE_KEYS.silentLoginTried, "1");
310
+ await authorize(window.location.href, "none", true);
311
+ return new Promise(() => void 0);
312
+ }
313
+ unauthenticate();
314
+ } catch (error) {
315
+ unauthenticate(toAccountError(error));
316
+ }
317
+ };
318
+ const client = {
319
+ store,
320
+ getAccessToken() {
321
+ return store.getState().accessToken;
322
+ },
323
+ ready() {
324
+ initializing ??= (callback ? finishCallback(callback) : restoreSession()).finally(() => {
325
+ settled = true;
326
+ });
327
+ return initializing;
328
+ },
329
+ isHandlingRedirect() {
330
+ return !settled && (callback !== null || trySilentLogin);
331
+ },
332
+ async requireAuth({ returnTo } = {}) {
333
+ await client.ready();
334
+ if (store.getState().status === "authenticated") return;
335
+ if (callbackError) throw callbackError;
336
+ await client.login({ returnTo });
337
+ return new Promise(() => void 0);
338
+ },
339
+ async login({ returnTo = window.location.href, prompt } = {}) {
340
+ await authorize(returnTo, prompt, false);
341
+ },
342
+ async retryLogin(body) {
343
+ if (rejection?.ticket === void 0) throw new AccountError$1("unauthenticated", { description: "no login to retry" });
344
+ try {
345
+ await startSession({ ticket: rejection.ticket }, body);
346
+ } catch (error) {
347
+ unauthenticate(toAccountError(error));
348
+ throw error;
349
+ }
350
+ },
351
+ async logout({ idp = false, returnTo = "/" } = {}) {
352
+ const { accessToken } = store.getState();
353
+ rejection = null;
354
+ localStorage.setItem(STORAGE_KEYS.loggedOut, "1");
355
+ unauthenticate();
356
+ await session.end(accessToken).catch(() => void 0);
357
+ if (!idp) return;
358
+ const logout = createLogoutUrl(await metadata(), {
359
+ clientId,
360
+ postLogoutRedirectUri: redirectUri()
361
+ });
362
+ if (!logout) return;
363
+ transactions.setItem(STORAGE_KEYS.logoutPrefix + logout.state, sameOriginPath(returnTo) ?? "/");
364
+ redirect(logout.url);
365
+ },
366
+ async refresh(staleToken = store.getState().accessToken) {
367
+ await refreshMutex.acquire();
368
+ try {
369
+ const { accessToken } = store.getState();
370
+ if (accessToken && accessToken !== staleToken) return accessToken;
371
+ const tokens = await session.refresh();
372
+ if (!tokens) {
373
+ unauthenticate();
374
+ return null;
375
+ }
376
+ authenticate(tokens.accessToken);
377
+ return tokens.accessToken;
378
+ } catch (error) {
379
+ unauthenticate(toAccountError(error));
380
+ return null;
381
+ } finally {
382
+ refreshMutex.release();
383
+ }
384
+ },
385
+ async fetch(input, init) {
386
+ await client.ready();
387
+ const send = (token) => {
388
+ const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : void 0));
389
+ if (token) headers.set(AUTHORIZATION_HEADER, bearer(token));
390
+ const request = input instanceof Request ? input.clone() : input;
391
+ return (fetch ?? globalThis.fetch)(request, {
392
+ ...init,
393
+ headers
394
+ });
395
+ };
396
+ const token = client.getAccessToken();
397
+ const response = await send(token);
398
+ if (!isUnauthorized(response.status)) return response;
399
+ const refreshed = await client.refresh(token);
400
+ return refreshed ? send(refreshed) : response;
401
+ }
402
+ };
403
+ return client;
404
+ }
405
+ function sessionFromApi(api, fetch) {
406
+ const { baseUrl, paths = {} } = isString(api) ? { baseUrl: api } : api;
407
+ return apiSession(baseUrl, paths, fetch);
408
+ }
409
+ /** 사용자가 직접 로그인하면 되는 자동 로그인 실패 코드다. 에러로 알리지 않는다. */
410
+ const SILENT_LOGIN_FAILURES = /* @__PURE__ */ new Set([
411
+ "login_required",
412
+ "consent_required",
413
+ "interaction_required"
414
+ ]);
415
+ /**
416
+ * 동기로 처리해야 한다. 라우터가 만들어지기 전에 주소가 바뀌어 있어야 라우터가 callback 주소를 라우팅하지 않는다.
417
+ * 같은 이유로 돌아갈 위치가 든 트랜잭션도 미리 꺼낸다.
418
+ */
419
+ function takeCallback(redirectUri, transactions) {
420
+ const url = window.location.href;
421
+ if (new URL(url).pathname !== new URL(redirectUri).pathname) return null;
422
+ const logoutKey = STORAGE_KEYS.logoutPrefix + readCallbackState(url);
423
+ const afterLogout = transactions.getItem(logoutKey);
424
+ if (afterLogout !== null && !isCallbackUrl(url)) {
425
+ transactions.removeItem(logoutKey);
426
+ window.history.replaceState(null, "", afterLogout);
427
+ return null;
428
+ }
429
+ if (!isCallbackUrl(url)) return null;
430
+ const transaction = takeTransaction(transactions, readCallbackState(url));
431
+ window.history.replaceState(null, "", transaction?.returnTo ?? "/");
432
+ return {
433
+ url,
434
+ transaction
435
+ };
436
+ }
437
+ /** 로그인 뒤 사용자를 다른 사이트로 보내지 않도록 다른 origin은 버린다. */
438
+ function sameOriginPath(value) {
439
+ const [, url] = attempt(() => new URL(value, window.location.origin));
440
+ if (url?.origin !== window.location.origin) return void 0;
441
+ return url.pathname + url.search + url.hash;
442
+ }
443
+ function toAccountError(error) {
444
+ return AccountError$1.is(error) ? error : new AccountError$1("unknown", { cause: error });
445
+ }
446
+ //#endregion
447
+ export { AccountClientOptionsSchema, AccountError, createAccountClient };
@@ -0,0 +1,9 @@
1
+ import { t as AccountClient } from "./create-account-client-MB6AawN9.js";
2
+ import { Middleware } from "openapi-fetch";
3
+ //#region src/adapters/openapi-fetch.d.ts
4
+ /**
5
+ * 401이면 refresh 한 뒤 한 번 다시 시도한다. 재시도는 middleware를 거치지 않는 `options.fetch`로 보내므로
6
+ * 무한히 반복되지 않는다.
7
+ */
8
+ export declare function createAccountMiddleware(client: AccountClient): Middleware;
9
+ //#endregion
@@ -0,0 +1,27 @@
1
+ import { AUTHORIZATION_HEADER, bearer, isUnauthorized, readBearer } from "@infoteam/account-core";
2
+ //#region src/adapters/openapi-fetch.ts
3
+ /**
4
+ * 401이면 refresh 한 뒤 한 번 다시 시도한다. 재시도는 middleware를 거치지 않는 `options.fetch`로 보내므로
5
+ * 무한히 반복되지 않는다.
6
+ */
7
+ function createAccountMiddleware(client) {
8
+ return {
9
+ async onRequest({ request }) {
10
+ await client.ready();
11
+ const token = client.getAccessToken();
12
+ if (token) request.headers.set(AUTHORIZATION_HEADER, bearer(token));
13
+ return request;
14
+ },
15
+ async onResponse({ request, response, options }) {
16
+ if (!isUnauthorized(response.status)) return response;
17
+ const stale = readBearer(request.headers.get(AUTHORIZATION_HEADER));
18
+ const token = await client.refresh(stale);
19
+ if (!token) return response;
20
+ const retry = new Request(request);
21
+ retry.headers.set(AUTHORIZATION_HEADER, bearer(token));
22
+ return options.fetch(retry);
23
+ }
24
+ };
25
+ }
26
+ //#endregion
27
+ export { createAccountMiddleware };
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "@infoteam/account-web",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.6.1",
4
+ "description": "Browser client for the Infoteam account IdP: sign-in redirect, callback handling and service sessions",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/gsainfoteam/account-sdk.git",
10
+ "directory": "packages/web"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "sideEffects": false,
19
+ "exports": {
20
+ ".": "./dist/index.js",
21
+ "./openapi-fetch": "./dist/openapi-fetch.js",
22
+ "./axios": "./dist/axios.js",
23
+ "./package.json": "./package.json"
24
+ },
25
+ "dependencies": {
26
+ "@infoteam/account-core": "^0.6.1",
27
+ "es-toolkit": "^1.52.0",
28
+ "zustand": "^5.0.15"
29
+ },
30
+ "peerDependencies": {
31
+ "axios": "^1.0.0",
32
+ "openapi-fetch": ">=0.13.0",
33
+ "zod": "^4.0.0"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "axios": {
37
+ "optional": true
38
+ },
39
+ "openapi-fetch": {
40
+ "optional": true
41
+ }
42
+ },
43
+ "devDependencies": {
44
+ "axios": "^1.20.0",
45
+ "happy-dom": "^20.14.5",
46
+ "openapi-fetch": "^0.17.0",
47
+ "tsdown": "^0.23.0",
48
+ "typescript": "~6.0.3",
49
+ "vitest": "^5.0.3",
50
+ "zod": "^4.6.5"
51
+ },
52
+ "scripts": {
53
+ "build": "tsdown",
54
+ "typecheck": "tsc --noEmit",
55
+ "lint": "eslint .",
56
+ "test": "vitest run"
57
+ }
6
58
  }