@msdongcha/auth-kit 0.0.0-stage → 0.0.2

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/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.0.1
4
+
5
+ - 初始发布:无 UI Auth-kit 核心、配置校验、跳转决策、显式 SessionAdapter 与 refresh single-flight。
package/README.md CHANGED
@@ -1,3 +1,152 @@
1
- # Temporary Holding Version
1
+ # @msdongcha/auth-kit
2
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.
3
+ ## 初始化
4
+
5
+ ```bash
6
+ pnpm add @msdongcha/auth-kit
7
+ ```
8
+
9
+ `TokenStore` 由业务应用实现。SDK 只通过它读取、轮换和清理 token;不会读取 React、Router、页面状态或浏览器存储。
10
+
11
+ ```ts
12
+ import { create, type TokenStore } from '@msdongcha/auth-kit'
13
+
14
+ const tokenStore: TokenStore = {
15
+ get: () => {
16
+ const accessToken = sessionStorage.getItem('access_token')
17
+ const refreshToken = sessionStorage.getItem('refresh_token')
18
+ return accessToken ? { accessToken, refreshToken: refreshToken ?? undefined } : null
19
+ },
20
+ set: ({ accessToken, refreshToken }) => {
21
+ sessionStorage.setItem('access_token', accessToken)
22
+ if (refreshToken) sessionStorage.setItem('refresh_token', refreshToken)
23
+ else sessionStorage.removeItem('refresh_token')
24
+ },
25
+ clear: () => {
26
+ sessionStorage.removeItem('access_token')
27
+ sessionStorage.removeItem('refresh_token')
28
+ },
29
+ }
30
+
31
+ const auth = create({
32
+ authDomain: 'https://iam.example.com',
33
+ app: 'business-web',
34
+ tokenStore,
35
+ })
36
+ ```
37
+
38
+ `authDomain` 必须是 HTTPS;仅当 `runtime` 为 `development` 或 `test` 时允许 localhost HTTP。`app` 是认证入口使用的应用标识。`fetchImpl` 用于注入 fetch 实现或测试替身。
39
+
40
+ 不要配置 `allowedCallbackOrigins`。回跳地址的白名单只由 IAM 服务端配置和校验,SDK 不在客户端复制这项授权决策。
41
+
42
+ ## `create(config)` 返回的客户端
43
+
44
+ ### `validate()`
45
+
46
+ 校验认证域名及当前 runtime 是否允许该 URL。
47
+
48
+ ```ts
49
+ const result = auth.validate()
50
+ if (!result.ok) console.error(result.error)
51
+ ```
52
+
53
+ ### `buildAuthorizeUrl(options?)`
54
+
55
+ 创建 IAM 登录地址,只输出 `app`、`return_to` 和可选的 `state` 参数。
56
+
57
+ ```ts
58
+ const result = auth.buildAuthorizeUrl({
59
+ returnTo: 'https://business.example.com/orders',
60
+ state: 'csrf-state',
61
+ })
62
+
63
+ if (result.ok) window.location.assign(result.value)
64
+ ```
65
+
66
+ 显式传入 `returnTo` 时优先使用。浏览器中省略它时,SDK 使用当前页面的绝对 URL,并删除查询或 hash 中的 `access_token`、`refresh_token`、`code`、`id_token` 和 `token` 参数。非浏览器环境必须显式传入有效的 `returnTo`,否则返回 `RETURN_TO_UNAVAILABLE`。SDK 只校验 URL 格式与 runtime;最终回跳是否允许由 IAM 服务端决定。
67
+
68
+ `appCode`、`clientId` 和 `callbackUrl` 仍是迁移期的兼容别名,分别归一化为 `app` 和 `returnTo`;SDK 不会输出 `client_id` 或 `callback_url`。
69
+
70
+ ### `login(username, password)`
71
+
72
+ 向 `POST /api/v1/auth/token` 发送 `{ username, password }`,将返回的 token 写入 `TokenStore`,并返回 token、可选的可信上下文和身份信息。
73
+
74
+ ```ts
75
+ const result = await auth.login('alice', 'secret')
76
+ if (!result.ok) throw new Error(result.error)
77
+ ```
78
+
79
+ ### `refreshToken()`
80
+
81
+ 使用 `TokenStore` 中的 refresh token 调用 `POST /api/v1/auth/refresh`。同一客户端实例中的并发刷新会合并为一次请求;成功后轮换 token,失败时清空 token。
82
+
83
+ ```ts
84
+ const result = await auth.refreshToken()
85
+ if (!result.ok) window.location.assign('/login')
86
+ ```
87
+
88
+ ### `getIdentity()`
89
+
90
+ 从 `TokenStore` 读取 access token,以 Bearer token 调用 `GET /api/v1/auth/me`,返回 IAM 远程响应中的 user、roles 和 permissions。SDK 不使用 JWKS,也不在本地解析 token。
91
+
92
+ ```ts
93
+ const result = await auth.getIdentity()
94
+ if (result.ok && result.value.permissions.includes('orders:read')) {
95
+ // 渲染订单入口
96
+ }
97
+ ```
98
+
99
+ ### `getContext()`
100
+
101
+ 同样通过 `GET /api/v1/auth/me` 获取远程数据,但只在响应包含可信的 `principal_kind`、`selected_tenant`、`default_target` 和 `app_access` 时返回 `AuthContext`。缺少这些字段时返回 `AUTH_CONTEXT_MISSING`;`app_access.allowed` 为 false 时返回 `APP_ACCESS_DENIED`。不会从 roles 或 memberships 推断上下文。
102
+
103
+ ```ts
104
+ const result = await auth.getContext()
105
+ if (result.ok) console.log(result.value.selectedTenant)
106
+ ```
107
+
108
+ ### `logout()`
109
+
110
+ 只清空 `TokenStore`,不发送网络请求。
111
+
112
+ ```ts
113
+ await auth.logout()
114
+ ```
115
+
116
+ ## 其他公开工具
117
+
118
+ ### `sanitizeCallbackUrl(url)`
119
+
120
+ 返回已删除认证参数的路径、查询和 hash,适合回调页在写入 TokenStore 后清理浏览器地址。
121
+
122
+ ```ts
123
+ window.history.replaceState(null, '', sanitizeCallbackUrl(window.location.href))
124
+ ```
125
+
126
+ ### `resolveTenantDecision(input)`
127
+
128
+ 优先消费 `AuthContext` 中受信任的 selected tenant。只有显式设置 `allowLegacyDefault: true` 时,才兼容一个激活的 legacy `isDefault` membership。
129
+
130
+ ```ts
131
+ const decision = resolveTenantDecision({ context: authContext })
132
+ if (decision.ok) useTenant(decision.value.selectedTenant.id)
133
+ ```
134
+
135
+ ### `createAuthKit(config)`
136
+
137
+ 旧版兼容入口。它返回 `validateConfiguration()`、`buildAuthorizeUrl()`、`decideDestination(context)` 和 `applySession(result, adapter?)`。新业务使用 `create()`;此入口仅保留给仍依赖旧配置和返回形状的调用方。
138
+
139
+ ### `createRefreshCoordinator()`
140
+
141
+ 创建旧版 refresh single-flight 协调器。`refresh(task)` 会让同一实例中并发的布尔刷新任务共用一个 Promise。
142
+
143
+ ```ts
144
+ const coordinator = createRefreshCoordinator()
145
+ await coordinator.refresh(() => refreshBusinessSession())
146
+ ```
147
+
148
+ ## 公开类型与结果
149
+
150
+ `AuthKitConfig`、`AuthClient`、`TokenStore`、`TokenSet`、`AuthIdentity`、`AuthContext`、`AuthKitResult`、`LoginResult`、`AuthorizeOptions`、`TenantDecision` 和相关 legacy 类型均从包根导出。由 `create()` 返回的客户端方法返回 `{ ok: true, value }` 或 `{ ok: false, error, retryable? }`;调用方应先判断 `ok` 再读取 `value`。
151
+
152
+ SDK 可以解包 IAM 的 `{ success, message, data }` 响应信封,也兼容裸 JSON 响应。它不实现 IAM 后端授权、回跳白名单或 JWKS token 校验。
@@ -0,0 +1,152 @@
1
+ export type PrincipalKind = 'PLATFORM_ADMIN' | 'BUSINESS_TENANT_MEMBER' | 'BUSINESS_ONLY' | 'TENANT_OR_BUSINESS';
2
+ export interface SelectedTenant {
3
+ id: string;
4
+ membershipId: string;
5
+ }
6
+ export interface DefaultTarget {
7
+ kind: 'PLATFORM' | 'BUSINESS';
8
+ url: string;
9
+ }
10
+ export interface AppAccess {
11
+ allowed: boolean;
12
+ roles: string[];
13
+ }
14
+ export interface AuthContext {
15
+ userId?: string;
16
+ principalKind?: PrincipalKind;
17
+ selectedTenant?: SelectedTenant;
18
+ defaultTarget?: DefaultTarget;
19
+ appAccess?: AppAccess;
20
+ permissions?: string[];
21
+ expiresAt?: number;
22
+ clientId?: string;
23
+ appCode?: string;
24
+ tenantId?: string;
25
+ membershipId?: string;
26
+ tenantPlatformUrl?: string;
27
+ callbackUrl?: string;
28
+ }
29
+ export interface AuthResult<T> {
30
+ context?: AuthContext;
31
+ accessToken?: string;
32
+ refreshToken?: string;
33
+ value?: T;
34
+ }
35
+ export interface TokenSet {
36
+ accessToken: string;
37
+ refreshToken?: string;
38
+ expiresIn?: number;
39
+ }
40
+ export interface TokenStore {
41
+ get: () => TokenSet | null;
42
+ set: (tokens: TokenSet) => void;
43
+ clear: () => void;
44
+ }
45
+ export interface SessionAdapter<T = void> {
46
+ set: (result: AuthResult<unknown>) => T | Promise<T>;
47
+ clear?: () => void | Promise<void>;
48
+ }
49
+ export interface AuthKitConfig {
50
+ authDomain: string;
51
+ app?: string;
52
+ /** @deprecated 请改用 app。 */
53
+ appCode?: string;
54
+ /** @deprecated 请改用 app。 */
55
+ clientId?: string;
56
+ tokenStore?: TokenStore;
57
+ fetchImpl?: typeof fetch;
58
+ runtime?: 'development' | 'test' | 'staging' | 'production';
59
+ }
60
+ export interface LegacyAuthKitConfig {
61
+ clientId: string;
62
+ appCode: string;
63
+ callbackUrl: string;
64
+ loginEntryUrl?: string;
65
+ runtimeLoginEntryUrl?: string;
66
+ buildLoginEntryUrl?: string;
67
+ developmentLoginEntryUrl?: string;
68
+ allowedLoginOrigins?: readonly string[];
69
+ isLoginHost?: boolean;
70
+ }
71
+ export type AuthErrorCode = 'AUTH_DOMAIN_MISSING' | 'AUTH_DOMAIN_INVALID' | 'CALLBACK_URL_INVALID' | 'RETURN_TO_UNAVAILABLE' | 'AUTH_ENTRY_CALLBACK_LOOP' | 'LOGIN_REQUIRED' | 'INVALID_CREDENTIALS' | 'AUTH_CONTEXT_MISSING' | 'PRINCIPAL_CONTEXT_INVALID' | 'TENANT_CONTEXT_INVALID' | 'TENANT_ACCESS_DENIED' | 'APP_ACCESS_DENIED' | 'CALLBACK_REJECTED' | 'TOKEN_EXPIRED' | 'REFRESH_FAILED' | 'LOGOUT_FAILED' | 'AUTH_PROTOCOL_UNAVAILABLE';
72
+ export type AuthKitError = AuthErrorCode | 'LOGIN_ENTRY_URL_MISSING' | 'INVALID_LOGIN_ENTRY_URL' | 'LOGIN_ENTRY_ORIGIN_DENIED' | 'INVALID_CALLBACK' | 'CROSS_APP_CONTEXT' | 'IDENTITY_CONTEXT_MISSING';
73
+ export type AuthKitResult<T> = {
74
+ ok: true;
75
+ value: T;
76
+ } | {
77
+ ok: false;
78
+ error: AuthKitError;
79
+ retryable?: boolean;
80
+ };
81
+ export interface LoginResult extends TokenSet {
82
+ context?: AuthContext;
83
+ identity?: AuthIdentity;
84
+ }
85
+ export interface AuthIdentity {
86
+ userId: string;
87
+ userName?: string;
88
+ tenantId?: string;
89
+ membershipId?: string;
90
+ roles: string[];
91
+ permissions: string[];
92
+ attrs?: Record<string, unknown>;
93
+ }
94
+ export interface AuthorizeOptions {
95
+ returnTo?: string;
96
+ app?: string;
97
+ /** @deprecated 请改用 returnTo。 */
98
+ callbackUrl?: string;
99
+ /** @deprecated 请改用 app。 */
100
+ appCode?: string;
101
+ /** @deprecated 请改用 app。 */
102
+ clientId?: string;
103
+ state?: string;
104
+ }
105
+ export interface AuthClient {
106
+ validate: () => AuthKitResult<void>;
107
+ buildAuthorizeUrl: (options?: AuthorizeOptions) => AuthKitResult<string>;
108
+ login: (username: string, password: string) => Promise<AuthKitResult<LoginResult>>;
109
+ refreshToken: () => Promise<AuthKitResult<LoginResult>>;
110
+ getIdentity: () => Promise<AuthKitResult<AuthIdentity>>;
111
+ getContext: () => Promise<AuthKitResult<AuthContext>>;
112
+ logout: () => Promise<AuthKitResult<void>>;
113
+ }
114
+ /** 清理不应保留在 history 或 referrer 的认证参数。 */
115
+ export declare function sanitizeCallbackUrl(value: string): string;
116
+ export interface LegacyMembershipCandidate {
117
+ membershipId: string;
118
+ tenantId: string;
119
+ membershipStatus?: string;
120
+ isDefault?: boolean | number;
121
+ appAccess?: boolean;
122
+ }
123
+ export interface TenantDecision {
124
+ selectedTenant: SelectedTenant;
125
+ source: 'trusted-context' | 'legacy-is-default';
126
+ }
127
+ /** 只消费后端 selected tenant;旧接口仅允许 is_default 兼容,不猜 joined_at 或身份。 */
128
+ export declare function resolveTenantDecision(input: {
129
+ context?: Pick<AuthContext, 'principalKind' | 'selectedTenant' | 'appAccess'>;
130
+ memberships?: readonly LegacyMembershipCandidate[];
131
+ allowLegacyDefault?: boolean;
132
+ }): AuthKitResult<TenantDecision>;
133
+ export declare function create(config: AuthKitConfig): AuthClient;
134
+ /** 创建旧版兼容实例;新业务接入请使用 create。 */
135
+ export declare function createAuthKit(config: LegacyAuthKitConfig): {
136
+ validateConfiguration: () => AuthKitResult<{
137
+ loginEntryUrl: string;
138
+ }>;
139
+ buildAuthorizeUrl: (_requestedTenantId?: string) => AuthKitResult<{
140
+ url: string;
141
+ }>;
142
+ decideDestination: (context: AuthContext) => AuthKitResult<{
143
+ url: string;
144
+ ignoredCallback: boolean;
145
+ }>;
146
+ applySession: (result: AuthResult<unknown>, adapter?: SessionAdapter) => Promise<AuthResult<unknown>>;
147
+ };
148
+ /** 同一标签页只保留一个刷新请求;传输和存储仍由宿主负责。 */
149
+ export declare function createRefreshCoordinator(): {
150
+ refresh(task: () => Promise<boolean>): Promise<boolean>;
151
+ };
152
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js ADDED
@@ -0,0 +1,360 @@
1
+ const SENSITIVE_CALLBACK_PARAMS = ['access_token', 'refresh_token', 'code', 'id_token', 'token'];
2
+ function removeSensitiveCallbackParams(url) {
3
+ for (const param of SENSITIVE_CALLBACK_PARAMS)
4
+ url.searchParams.delete(param);
5
+ const hash = url.hash.slice(1);
6
+ const queryIndex = hash.indexOf('?');
7
+ const hashQuery = queryIndex >= 0 ? hash.slice(queryIndex + 1) : hash.includes('=') ? hash : null;
8
+ if (!hashQuery)
9
+ return url;
10
+ const hashParams = new URLSearchParams(hashQuery);
11
+ for (const param of SENSITIVE_CALLBACK_PARAMS)
12
+ hashParams.delete(param);
13
+ const sanitizedHashQuery = hashParams.toString();
14
+ url.hash = queryIndex >= 0
15
+ ? sanitizedHashQuery ? `${hash.slice(0, queryIndex)}?${sanitizedHashQuery}` : hash.slice(0, queryIndex)
16
+ : sanitizedHashQuery;
17
+ return url;
18
+ }
19
+ /** 清理不应保留在 history 或 referrer 的认证参数。 */
20
+ export function sanitizeCallbackUrl(value) {
21
+ const url = removeSensitiveCallbackParams(new URL(value));
22
+ return `${url.pathname}${url.search}${url.hash}`;
23
+ }
24
+ function parseHttpUrl(value, allowLocalhost = true) {
25
+ try {
26
+ const url = new URL(value);
27
+ if (url.protocol === 'https:')
28
+ return url;
29
+ if (allowLocalhost && url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]'))
30
+ return url;
31
+ return null;
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ function currentBrowserUrl() {
38
+ if (typeof window === 'undefined')
39
+ return null;
40
+ try {
41
+ return removeSensitiveCallbackParams(new URL(window.location.href)).toString();
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ function ok(value) {
48
+ return { ok: true, value };
49
+ }
50
+ function fail(error, retryable = false) {
51
+ return retryable ? { ok: false, error, retryable: true } : { ok: false, error };
52
+ }
53
+ function isRecord(value) {
54
+ return Boolean(value && typeof value === 'object');
55
+ }
56
+ /** 认证服务与业务接口统一使用响应信封,SDK 对外仍返回既有领域对象。 */
57
+ function unwrapResponse(value) {
58
+ if (!isRecord(value) || !('success' in value))
59
+ return { ok: true, value };
60
+ return value.success === true ? { ok: true, value: value.data } : { ok: false };
61
+ }
62
+ function stringValue(value) {
63
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
64
+ }
65
+ function normalizeContext(value) {
66
+ if (!isRecord(value))
67
+ return null;
68
+ const principalKind = stringValue(value.principal_kind ?? value.principalKind);
69
+ const tenant = (value.selected_tenant ?? value.selectedTenant);
70
+ const target = (value.default_target ?? value.defaultTarget);
71
+ const access = (value.app_access ?? value.appAccess);
72
+ const tenantId = stringValue(tenant?.id ?? value.tenantId);
73
+ const membershipId = stringValue(tenant?.membership_id ?? tenant?.membershipId ?? value.membershipId);
74
+ const appCode = stringValue(value.app_code ?? value.appCode);
75
+ const userId = stringValue(value.user_id ?? value.userId);
76
+ if (!principalKind || !tenantId || !membershipId || !appCode || !userId || !target || !access)
77
+ return null;
78
+ const targetKind = target.kind;
79
+ const targetUrl = stringValue(target.url);
80
+ if ((targetKind !== 'PLATFORM' && targetKind !== 'BUSINESS') || !targetUrl || typeof access.allowed !== 'boolean')
81
+ return null;
82
+ return {
83
+ userId,
84
+ principalKind,
85
+ selectedTenant: { id: tenantId, membershipId },
86
+ defaultTarget: { kind: targetKind, url: targetUrl },
87
+ appCode,
88
+ appAccess: { allowed: access.allowed, roles: Array.isArray(access.roles) ? access.roles.filter((role) => typeof role === 'string') : [] },
89
+ permissions: Array.isArray(value.permissions) ? value.permissions.filter((permission) => typeof permission === 'string') : [],
90
+ expiresAt: typeof value.expires_at === 'number' ? value.expires_at : typeof value.expiresAt === 'number' ? value.expiresAt : undefined,
91
+ };
92
+ }
93
+ function toTokenSet(value) {
94
+ if (!isRecord(value))
95
+ return null;
96
+ const accessToken = stringValue(value.access_token ?? value.accessToken);
97
+ if (!accessToken)
98
+ return null;
99
+ const refreshToken = stringValue(value.refresh_token ?? value.refreshToken);
100
+ const expiresIn = typeof value.expires_in === 'number' ? value.expires_in : typeof value.expiresIn === 'number' ? value.expiresIn : undefined;
101
+ return { accessToken, refreshToken, expiresIn };
102
+ }
103
+ function normalizeIdentity(value) {
104
+ if (!isRecord(value))
105
+ return null;
106
+ const nestedUser = isRecord(value.user) ? value.user : value;
107
+ const userId = stringValue(nestedUser.user_id ?? nestedUser.userId);
108
+ if (!userId)
109
+ return null;
110
+ const userName = stringValue(nestedUser.user_name ?? nestedUser.userName);
111
+ const tenantId = stringValue(value.tenantId ?? nestedUser.tenant_id ?? nestedUser.tenantId);
112
+ const membershipId = stringValue(value.membershipId ?? nestedUser.membership_id ?? nestedUser.membershipId);
113
+ const attrs = isRecord(value.attrs) ? value.attrs : undefined;
114
+ return {
115
+ userId,
116
+ ...(userName ? { userName } : {}),
117
+ ...(tenantId ? { tenantId } : {}),
118
+ ...(membershipId ? { membershipId } : {}),
119
+ roles: Array.isArray(nestedUser.roles) ? nestedUser.roles.filter((role) => typeof role === 'string') : [],
120
+ permissions: Array.isArray(nestedUser.permissions) ? nestedUser.permissions.filter((permission) => typeof permission === 'string') : [],
121
+ ...(attrs ? { attrs } : {}),
122
+ };
123
+ }
124
+ /** 只消费后端 selected tenant;旧接口仅允许 is_default 兼容,不猜 joined_at 或身份。 */
125
+ export function resolveTenantDecision(input) {
126
+ const context = input.context;
127
+ const trustedPrincipal = context?.principalKind === 'PLATFORM_ADMIN'
128
+ || context?.principalKind === 'BUSINESS_TENANT_MEMBER'
129
+ || context?.principalKind === 'BUSINESS_ONLY'
130
+ || context?.principalKind === 'TENANT_OR_BUSINESS';
131
+ if (trustedPrincipal && context?.selectedTenant && context.appAccess) {
132
+ if (!context.appAccess.allowed)
133
+ return fail('APP_ACCESS_DENIED');
134
+ return ok({ selectedTenant: context.selectedTenant, source: 'trusted-context' });
135
+ }
136
+ if (!input.allowLegacyDefault)
137
+ return fail('AUTH_CONTEXT_MISSING');
138
+ const candidates = (input.memberships ?? []).filter((membership) => (membership.membershipStatus === undefined || membership.membershipStatus === 'ACTIVE')
139
+ && (membership.isDefault === true || membership.isDefault === 1));
140
+ const accessible = candidates.filter((membership) => membership.appAccess !== false);
141
+ if (accessible.length !== 1)
142
+ return fail('TENANT_CONTEXT_INVALID');
143
+ return ok({
144
+ selectedTenant: { id: accessible[0].tenantId, membershipId: accessible[0].membershipId },
145
+ source: 'legacy-is-default',
146
+ });
147
+ }
148
+ function createMemoryStore() {
149
+ let tokens = null;
150
+ return {
151
+ get: () => tokens,
152
+ set: (next) => { tokens = next; },
153
+ clear: () => { tokens = null; },
154
+ };
155
+ }
156
+ function responseError(status) {
157
+ if (status === 401)
158
+ return fail('INVALID_CREDENTIALS');
159
+ if (status === 403)
160
+ return fail('APP_ACCESS_DENIED');
161
+ if (status >= 500)
162
+ return fail('AUTH_PROTOCOL_UNAVAILABLE', true);
163
+ return fail('AUTH_PROTOCOL_UNAVAILABLE', status >= 408);
164
+ }
165
+ export function create(config) {
166
+ const fetchImpl = config.fetchImpl ?? fetch;
167
+ const tokenStore = config.tokenStore ?? createMemoryStore();
168
+ const authDomain = config.authDomain.replace(/\/$/, '');
169
+ let refreshPending = null;
170
+ function validate() {
171
+ if (!config.authDomain)
172
+ return fail('AUTH_DOMAIN_MISSING');
173
+ if (!parseHttpUrl(config.authDomain, config.runtime === 'development' || config.runtime === 'test'))
174
+ return fail('AUTH_DOMAIN_INVALID');
175
+ return ok(undefined);
176
+ }
177
+ function buildAuthorizeUrl(options = {}) {
178
+ const valid = validate();
179
+ if (!valid.ok)
180
+ return valid;
181
+ const url = new URL('/auth/login', `${authDomain}/`);
182
+ const app = options.app ?? config.app ?? options.appCode ?? options.clientId ?? config.appCode ?? config.clientId ?? '';
183
+ const configuredReturnTo = options.returnTo ?? options.callbackUrl;
184
+ const returnTo = configuredReturnTo ?? currentBrowserUrl();
185
+ if (returnTo === null)
186
+ return fail('RETURN_TO_UNAVAILABLE');
187
+ const callbackUrl = parseHttpUrl(returnTo, config.runtime === 'development' || config.runtime === 'test');
188
+ if (!callbackUrl)
189
+ return fail('CALLBACK_URL_INVALID');
190
+ url.searchParams.set('app', app);
191
+ url.searchParams.set('return_to', callbackUrl.toString());
192
+ if (options.state)
193
+ url.searchParams.set('state', options.state);
194
+ return ok(url.toString());
195
+ }
196
+ async function login(username, password) {
197
+ const valid = validate();
198
+ if (!valid.ok)
199
+ return valid;
200
+ if (!username || !password)
201
+ return fail('INVALID_CREDENTIALS');
202
+ try {
203
+ const response = await fetchImpl(`${authDomain}/api/v1/auth/token`, {
204
+ method: 'POST',
205
+ headers: { 'Content-Type': 'application/json' },
206
+ body: JSON.stringify({ username, password }),
207
+ });
208
+ if (!response.ok)
209
+ return responseError(response.status);
210
+ const responseBody = unwrapResponse(await response.json());
211
+ if (!responseBody.ok)
212
+ return fail('AUTH_PROTOCOL_UNAVAILABLE');
213
+ const body = responseBody.value;
214
+ const tokens = toTokenSet(body);
215
+ if (!tokens)
216
+ return fail('AUTH_PROTOCOL_UNAVAILABLE');
217
+ tokenStore.set(tokens);
218
+ const context = isRecord(body) ? normalizeContext(body.context ?? body.auth_context) ?? undefined : undefined;
219
+ return ok({ ...tokens, context, identity: normalizeIdentity(body) ?? undefined });
220
+ }
221
+ catch {
222
+ return fail('AUTH_PROTOCOL_UNAVAILABLE', true);
223
+ }
224
+ }
225
+ async function runRefresh() {
226
+ const current = tokenStore.get();
227
+ if (!current?.refreshToken)
228
+ return fail('REFRESH_FAILED');
229
+ try {
230
+ const response = await fetchImpl(`${authDomain}/api/v1/auth/refresh`, {
231
+ method: 'POST',
232
+ headers: { 'Content-Type': 'application/json' },
233
+ body: JSON.stringify({ refresh_token: current.refreshToken }),
234
+ });
235
+ if (!response.ok) {
236
+ tokenStore.clear();
237
+ return response.status === 401 ? fail('TOKEN_EXPIRED') : responseError(response.status);
238
+ }
239
+ const responseBody = unwrapResponse(await response.json());
240
+ if (!responseBody.ok) {
241
+ tokenStore.clear();
242
+ return fail('AUTH_PROTOCOL_UNAVAILABLE');
243
+ }
244
+ const body = responseBody.value;
245
+ const tokens = toTokenSet(body);
246
+ if (!tokens) {
247
+ tokenStore.clear();
248
+ return fail('AUTH_PROTOCOL_UNAVAILABLE');
249
+ }
250
+ tokenStore.set(tokens);
251
+ return ok({ ...tokens, context: isRecord(body) ? normalizeContext(body.context ?? body.auth_context) ?? undefined : undefined, identity: normalizeIdentity(body) ?? undefined });
252
+ }
253
+ catch {
254
+ tokenStore.clear();
255
+ return fail('REFRESH_FAILED', true);
256
+ }
257
+ }
258
+ function refreshToken() {
259
+ if (!refreshPending)
260
+ refreshPending = runRefresh().finally(() => { refreshPending = null; });
261
+ return refreshPending;
262
+ }
263
+ async function getRemoteIdentity() {
264
+ const current = tokenStore.get();
265
+ if (!current?.accessToken)
266
+ return fail('LOGIN_REQUIRED');
267
+ try {
268
+ const response = await fetchImpl(`${authDomain}/api/v1/auth/me`, { headers: { Authorization: `Bearer ${current.accessToken}` } });
269
+ if (!response.ok)
270
+ return response.status === 401 ? fail('TOKEN_EXPIRED') : responseError(response.status);
271
+ const responseBody = unwrapResponse(await response.json());
272
+ if (!responseBody.ok)
273
+ return fail('AUTH_PROTOCOL_UNAVAILABLE');
274
+ return ok(responseBody.value);
275
+ }
276
+ catch {
277
+ return fail('AUTH_PROTOCOL_UNAVAILABLE', true);
278
+ }
279
+ }
280
+ async function getContext() {
281
+ const remoteIdentity = await getRemoteIdentity();
282
+ if (!remoteIdentity.ok)
283
+ return remoteIdentity;
284
+ const context = normalizeContext(remoteIdentity.value);
285
+ if (!context)
286
+ return fail('AUTH_CONTEXT_MISSING');
287
+ if (!context.appAccess?.allowed)
288
+ return fail('APP_ACCESS_DENIED');
289
+ return ok(context);
290
+ }
291
+ async function getIdentity() {
292
+ const remoteIdentity = await getRemoteIdentity();
293
+ if (!remoteIdentity.ok)
294
+ return remoteIdentity;
295
+ const identity = normalizeIdentity(remoteIdentity.value);
296
+ return identity ? ok(identity) : fail('AUTH_PROTOCOL_UNAVAILABLE');
297
+ }
298
+ async function logout() {
299
+ tokenStore.clear();
300
+ return ok(undefined);
301
+ }
302
+ return { validate, buildAuthorizeUrl, login, refreshToken, getIdentity, getContext, logout };
303
+ }
304
+ /** 创建旧版兼容实例;新业务接入请使用 create。 */
305
+ export function createAuthKit(config) {
306
+ const callbackUrl = parseHttpUrl(config.callbackUrl);
307
+ const configuredEntry = config.loginEntryUrl ?? config.runtimeLoginEntryUrl ?? config.buildLoginEntryUrl ?? config.developmentLoginEntryUrl;
308
+ const entryUrl = configuredEntry ? parseHttpUrl(configuredEntry) : null;
309
+ const allowedOrigins = config.allowedLoginOrigins ?? (entryUrl ? [entryUrl.origin] : []);
310
+ function validateConfiguration() {
311
+ if (!configuredEntry)
312
+ return fail('LOGIN_ENTRY_URL_MISSING');
313
+ if (!entryUrl || !callbackUrl)
314
+ return fail('INVALID_LOGIN_ENTRY_URL');
315
+ if (!allowedOrigins.includes(entryUrl.origin))
316
+ return fail('LOGIN_ENTRY_ORIGIN_DENIED');
317
+ return ok({ loginEntryUrl: entryUrl.toString() });
318
+ }
319
+ function buildAuthorizeUrl(_requestedTenantId) {
320
+ const valid = validateConfiguration();
321
+ if (!valid.ok)
322
+ return valid;
323
+ if (config.isLoginHost)
324
+ return fail('AUTH_ENTRY_CALLBACK_LOOP');
325
+ const url = new URL(valid.value.loginEntryUrl);
326
+ url.searchParams.set('app', config.appCode);
327
+ url.searchParams.set('return_to', callbackUrl.toString());
328
+ return ok({ url: url.toString() });
329
+ }
330
+ function decideDestination(context) {
331
+ if (!context.clientId || context.clientId !== config.clientId || !context.appCode || context.appCode !== config.appCode || !context.tenantId || !context.membershipId)
332
+ return fail('IDENTITY_CONTEXT_MISSING');
333
+ const platformUrl = context.tenantPlatformUrl ? parseHttpUrl(context.tenantPlatformUrl) : null;
334
+ if (!platformUrl)
335
+ return fail('IDENTITY_CONTEXT_MISSING');
336
+ if (context.principalKind === 'PLATFORM_ADMIN')
337
+ return ok({ url: platformUrl.toString(), ignoredCallback: Boolean(context.callbackUrl) });
338
+ const callback = context.callbackUrl ? parseHttpUrl(context.callbackUrl) : null;
339
+ if (callback && callback.origin === callbackUrl?.origin)
340
+ return ok({ url: callback.toString(), ignoredCallback: false });
341
+ return ok({ url: platformUrl.toString(), ignoredCallback: false });
342
+ }
343
+ async function applySession(result, adapter) {
344
+ if (adapter)
345
+ await adapter.set(result);
346
+ return result;
347
+ }
348
+ return { validateConfiguration, buildAuthorizeUrl, decideDestination, applySession };
349
+ }
350
+ /** 同一标签页只保留一个刷新请求;传输和存储仍由宿主负责。 */
351
+ export function createRefreshCoordinator() {
352
+ let pending = null;
353
+ return {
354
+ refresh(task) {
355
+ if (!pending)
356
+ pending = task().finally(() => { pending = null; });
357
+ return pending;
358
+ },
359
+ };
360
+ }
package/package.json CHANGED
@@ -1,6 +1,29 @@
1
1
  {
2
2
  "name": "@msdongcha/auth-kit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.2",
4
+ "description": "Framework-agnostic authentication context, redirect, callback and session coordination utilities.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "default": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": ["dist", "README.md", "CHANGELOG.md"],
17
+ "sideEffects": false,
18
+ "engines": { "node": ">=20" },
19
+ "license": "UNLICENSED",
20
+ "publishConfig": { "access": "public", "provenance": true },
21
+ "scripts": {
22
+ "build": "tsc -p tsconfig.build.json",
23
+ "typecheck": "tsc -p tsconfig.build.json --noEmit",
24
+ "lint": "tsc -p tsconfig.build.json --noEmit",
25
+ "test": "pnpm run build && node --test test/*.test.mjs",
26
+ "pack:dry-run": "pnpm pack --dry-run"
27
+ },
28
+ "devDependencies": { "typescript": "~6.0.2" }
29
+ }