@msdongcha/auth-kit 0.0.0-stage → 0.0.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/CHANGELOG.md +5 -0
- package/README.md +24 -2
- package/dist/index.d.ts +145 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +323 -0
- package/package.json +27 -4
package/CHANGELOG.md
ADDED
package/README.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @msdongcha/auth-kit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
无 UI、framework-agnostic 的认证域名校验、统一登录地址、账号密码登录、refresh、可信上下文消费与 logout 工具。它不包含 React、Router、页面、表单、样式或后端授权实现。
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { create } from '@msdongcha/auth-kit'
|
|
7
|
+
|
|
8
|
+
const auth = create({
|
|
9
|
+
authDomain: 'https://iam.example.com',
|
|
10
|
+
appCode: 'business-web',
|
|
11
|
+
allowedCallbackOrigins: ['https://business.example.com'],
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const authorizeUrl = auth.buildAuthorizeUrl({
|
|
15
|
+
callbackUrl: 'https://business.example.com/auth/callback',
|
|
16
|
+
})
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`getContext()` 需要后端返回可信 `principal_kind`、`selected_tenant`、`default_target` 和 `app_access`。当前服务端响应缺失这些字段时,SDK 返回 `AUTH_CONTEXT_MISSING` 或 `APP_ACCESS_DENIED`,不会从 roles 或 memberships 猜测。
|
|
20
|
+
|
|
21
|
+
`getIdentity()` 是旧 `/api/v1/auth/me` 的兼容读取入口,只用于 IAM 内部页面展示 user、tenant、membership、roles 和 permissions;它不能替代可信 `AuthContext`,也不能放行外部业务 callback。
|
|
22
|
+
|
|
23
|
+
应用应通过一个 `TokenStore` adapter 将 SDK 接到自己的 session store。SDK 负责 login/refresh HTTP 与 token rotation,应用业务 client 只负责 Bearer 注入和一次重试。
|
|
24
|
+
|
|
25
|
+
`createAuthKit` 和 `createRefreshCoordinator` 保留为旧版本兼容入口,新业务请使用 `create`。
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
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
|
+
appCode?: string;
|
|
52
|
+
clientId?: string;
|
|
53
|
+
allowedCallbackOrigins?: readonly string[];
|
|
54
|
+
tokenStore?: TokenStore;
|
|
55
|
+
fetchImpl?: typeof fetch;
|
|
56
|
+
runtime?: 'development' | 'test' | 'staging' | 'production';
|
|
57
|
+
}
|
|
58
|
+
export interface LegacyAuthKitConfig {
|
|
59
|
+
clientId: string;
|
|
60
|
+
appCode: string;
|
|
61
|
+
callbackUrl: string;
|
|
62
|
+
loginEntryUrl?: string;
|
|
63
|
+
runtimeLoginEntryUrl?: string;
|
|
64
|
+
buildLoginEntryUrl?: string;
|
|
65
|
+
developmentLoginEntryUrl?: string;
|
|
66
|
+
allowedLoginOrigins?: readonly string[];
|
|
67
|
+
isLoginHost?: boolean;
|
|
68
|
+
}
|
|
69
|
+
export type AuthErrorCode = 'AUTH_DOMAIN_MISSING' | 'AUTH_DOMAIN_INVALID' | 'CALLBACK_URL_INVALID' | 'CALLBACK_ORIGIN_DENIED' | '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';
|
|
70
|
+
export type AuthKitError = AuthErrorCode | 'LOGIN_ENTRY_URL_MISSING' | 'INVALID_LOGIN_ENTRY_URL' | 'LOGIN_ENTRY_ORIGIN_DENIED' | 'INVALID_CALLBACK' | 'CROSS_APP_CONTEXT' | 'IDENTITY_CONTEXT_MISSING';
|
|
71
|
+
export type AuthKitResult<T> = {
|
|
72
|
+
ok: true;
|
|
73
|
+
value: T;
|
|
74
|
+
} | {
|
|
75
|
+
ok: false;
|
|
76
|
+
error: AuthKitError;
|
|
77
|
+
retryable?: boolean;
|
|
78
|
+
};
|
|
79
|
+
export interface LoginResult extends TokenSet {
|
|
80
|
+
context?: AuthContext;
|
|
81
|
+
identity?: AuthIdentity;
|
|
82
|
+
}
|
|
83
|
+
export interface AuthIdentity {
|
|
84
|
+
userId: string;
|
|
85
|
+
userName?: string;
|
|
86
|
+
tenantId?: string;
|
|
87
|
+
membershipId?: string;
|
|
88
|
+
roles: string[];
|
|
89
|
+
permissions: string[];
|
|
90
|
+
attrs?: Record<string, unknown>;
|
|
91
|
+
}
|
|
92
|
+
export interface AuthorizeOptions {
|
|
93
|
+
callbackUrl: string;
|
|
94
|
+
appCode?: string;
|
|
95
|
+
clientId?: string;
|
|
96
|
+
state?: string;
|
|
97
|
+
}
|
|
98
|
+
export interface AuthClient {
|
|
99
|
+
validate: () => AuthKitResult<void>;
|
|
100
|
+
buildAuthorizeUrl: (options: AuthorizeOptions) => AuthKitResult<string>;
|
|
101
|
+
login: (username: string, password: string) => Promise<AuthKitResult<LoginResult>>;
|
|
102
|
+
refreshToken: () => Promise<AuthKitResult<LoginResult>>;
|
|
103
|
+
getIdentity: () => Promise<AuthKitResult<AuthIdentity>>;
|
|
104
|
+
getContext: () => Promise<AuthKitResult<AuthContext>>;
|
|
105
|
+
logout: () => Promise<AuthKitResult<void>>;
|
|
106
|
+
}
|
|
107
|
+
/** 清理回调 URL 中不应保留在 history 或 referrer 的认证参数。 */
|
|
108
|
+
export declare function sanitizeCallbackUrl(value: string): string;
|
|
109
|
+
export interface LegacyMembershipCandidate {
|
|
110
|
+
membershipId: string;
|
|
111
|
+
tenantId: string;
|
|
112
|
+
membershipStatus?: string;
|
|
113
|
+
isDefault?: boolean | number;
|
|
114
|
+
appAccess?: boolean;
|
|
115
|
+
}
|
|
116
|
+
export interface TenantDecision {
|
|
117
|
+
selectedTenant: SelectedTenant;
|
|
118
|
+
source: 'trusted-context' | 'legacy-is-default';
|
|
119
|
+
}
|
|
120
|
+
/** 只消费后端 selected tenant;旧接口仅允许 is_default 兼容,不猜 joined_at 或身份。 */
|
|
121
|
+
export declare function resolveTenantDecision(input: {
|
|
122
|
+
context?: Pick<AuthContext, 'principalKind' | 'selectedTenant' | 'appAccess'>;
|
|
123
|
+
memberships?: readonly LegacyMembershipCandidate[];
|
|
124
|
+
allowLegacyDefault?: boolean;
|
|
125
|
+
}): AuthKitResult<TenantDecision>;
|
|
126
|
+
export declare function create(config: AuthKitConfig): AuthClient;
|
|
127
|
+
/** 创建旧版兼容实例;新业务接入请使用 create。 */
|
|
128
|
+
export declare function createAuthKit(config: LegacyAuthKitConfig): {
|
|
129
|
+
validateConfiguration: () => AuthKitResult<{
|
|
130
|
+
loginEntryUrl: string;
|
|
131
|
+
}>;
|
|
132
|
+
buildAuthorizeUrl: (requestedTenantId?: string) => AuthKitResult<{
|
|
133
|
+
url: string;
|
|
134
|
+
}>;
|
|
135
|
+
decideDestination: (context: AuthContext) => AuthKitResult<{
|
|
136
|
+
url: string;
|
|
137
|
+
ignoredCallback: boolean;
|
|
138
|
+
}>;
|
|
139
|
+
applySession: (result: AuthResult<unknown>, adapter?: SessionAdapter) => Promise<AuthResult<unknown>>;
|
|
140
|
+
};
|
|
141
|
+
/** 同一标签页只保留一个刷新请求;传输和存储仍由宿主负责。 */
|
|
142
|
+
export declare function createRefreshCoordinator(): {
|
|
143
|
+
refresh(task: () => Promise<boolean>): Promise<boolean>;
|
|
144
|
+
};
|
|
145
|
+
//# 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,323 @@
|
|
|
1
|
+
/** 清理回调 URL 中不应保留在 history 或 referrer 的认证参数。 */
|
|
2
|
+
export function sanitizeCallbackUrl(value) {
|
|
3
|
+
const url = new URL(value);
|
|
4
|
+
url.searchParams.delete('access_token');
|
|
5
|
+
url.searchParams.delete('refresh_token');
|
|
6
|
+
url.searchParams.delete('code');
|
|
7
|
+
return `${url.pathname}${url.search}${url.hash}`;
|
|
8
|
+
}
|
|
9
|
+
function parseHttpUrl(value, allowLocalhost = true) {
|
|
10
|
+
try {
|
|
11
|
+
const url = new URL(value);
|
|
12
|
+
if (url.protocol === 'https:')
|
|
13
|
+
return url;
|
|
14
|
+
if (allowLocalhost && url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]'))
|
|
15
|
+
return url;
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function ok(value) {
|
|
23
|
+
return { ok: true, value };
|
|
24
|
+
}
|
|
25
|
+
function fail(error, retryable = false) {
|
|
26
|
+
return retryable ? { ok: false, error, retryable: true } : { ok: false, error };
|
|
27
|
+
}
|
|
28
|
+
function isRecord(value) {
|
|
29
|
+
return Boolean(value && typeof value === 'object');
|
|
30
|
+
}
|
|
31
|
+
function stringValue(value) {
|
|
32
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
33
|
+
}
|
|
34
|
+
function normalizeContext(value) {
|
|
35
|
+
if (!isRecord(value))
|
|
36
|
+
return null;
|
|
37
|
+
const principalKind = stringValue(value.principal_kind ?? value.principalKind);
|
|
38
|
+
const tenant = (value.selected_tenant ?? value.selectedTenant);
|
|
39
|
+
const target = (value.default_target ?? value.defaultTarget);
|
|
40
|
+
const access = (value.app_access ?? value.appAccess);
|
|
41
|
+
const tenantId = stringValue(tenant?.id ?? value.tenantId);
|
|
42
|
+
const membershipId = stringValue(tenant?.membership_id ?? tenant?.membershipId ?? value.membershipId);
|
|
43
|
+
const appCode = stringValue(value.app_code ?? value.appCode);
|
|
44
|
+
const userId = stringValue(value.user_id ?? value.userId);
|
|
45
|
+
if (!principalKind || !tenantId || !membershipId || !appCode || !userId || !target || !access)
|
|
46
|
+
return null;
|
|
47
|
+
const targetKind = target.kind;
|
|
48
|
+
const targetUrl = stringValue(target.url);
|
|
49
|
+
if ((targetKind !== 'PLATFORM' && targetKind !== 'BUSINESS') || !targetUrl || typeof access.allowed !== 'boolean')
|
|
50
|
+
return null;
|
|
51
|
+
return {
|
|
52
|
+
userId,
|
|
53
|
+
principalKind,
|
|
54
|
+
selectedTenant: { id: tenantId, membershipId },
|
|
55
|
+
defaultTarget: { kind: targetKind, url: targetUrl },
|
|
56
|
+
appCode,
|
|
57
|
+
appAccess: { allowed: access.allowed, roles: Array.isArray(access.roles) ? access.roles.filter((role) => typeof role === 'string') : [] },
|
|
58
|
+
permissions: Array.isArray(value.permissions) ? value.permissions.filter((permission) => typeof permission === 'string') : [],
|
|
59
|
+
expiresAt: typeof value.expires_at === 'number' ? value.expires_at : typeof value.expiresAt === 'number' ? value.expiresAt : undefined,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function toTokenSet(value) {
|
|
63
|
+
if (!isRecord(value))
|
|
64
|
+
return null;
|
|
65
|
+
const accessToken = stringValue(value.access_token ?? value.accessToken);
|
|
66
|
+
if (!accessToken)
|
|
67
|
+
return null;
|
|
68
|
+
const refreshToken = stringValue(value.refresh_token ?? value.refreshToken);
|
|
69
|
+
const expiresIn = typeof value.expires_in === 'number' ? value.expires_in : typeof value.expiresIn === 'number' ? value.expiresIn : undefined;
|
|
70
|
+
return { accessToken, refreshToken, expiresIn };
|
|
71
|
+
}
|
|
72
|
+
function normalizeIdentity(value) {
|
|
73
|
+
if (!isRecord(value))
|
|
74
|
+
return null;
|
|
75
|
+
const nestedUser = isRecord(value.user) ? value.user : value;
|
|
76
|
+
const userId = stringValue(nestedUser.user_id ?? nestedUser.userId);
|
|
77
|
+
if (!userId)
|
|
78
|
+
return null;
|
|
79
|
+
const userName = stringValue(nestedUser.user_name ?? nestedUser.userName);
|
|
80
|
+
const tenantId = stringValue(value.tenantId ?? nestedUser.tenant_id ?? nestedUser.tenantId);
|
|
81
|
+
const membershipId = stringValue(value.membershipId ?? nestedUser.membership_id ?? nestedUser.membershipId);
|
|
82
|
+
const attrs = isRecord(value.attrs) ? value.attrs : undefined;
|
|
83
|
+
return {
|
|
84
|
+
userId,
|
|
85
|
+
...(userName ? { userName } : {}),
|
|
86
|
+
...(tenantId ? { tenantId } : {}),
|
|
87
|
+
...(membershipId ? { membershipId } : {}),
|
|
88
|
+
roles: Array.isArray(nestedUser.roles) ? nestedUser.roles.filter((role) => typeof role === 'string') : [],
|
|
89
|
+
permissions: Array.isArray(nestedUser.permissions) ? nestedUser.permissions.filter((permission) => typeof permission === 'string') : [],
|
|
90
|
+
...(attrs ? { attrs } : {}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/** 只消费后端 selected tenant;旧接口仅允许 is_default 兼容,不猜 joined_at 或身份。 */
|
|
94
|
+
export function resolveTenantDecision(input) {
|
|
95
|
+
const context = input.context;
|
|
96
|
+
const trustedPrincipal = context?.principalKind === 'PLATFORM_ADMIN'
|
|
97
|
+
|| context?.principalKind === 'BUSINESS_TENANT_MEMBER'
|
|
98
|
+
|| context?.principalKind === 'BUSINESS_ONLY'
|
|
99
|
+
|| context?.principalKind === 'TENANT_OR_BUSINESS';
|
|
100
|
+
if (trustedPrincipal && context?.selectedTenant && context.appAccess) {
|
|
101
|
+
if (!context.appAccess.allowed)
|
|
102
|
+
return fail('APP_ACCESS_DENIED');
|
|
103
|
+
return ok({ selectedTenant: context.selectedTenant, source: 'trusted-context' });
|
|
104
|
+
}
|
|
105
|
+
if (!input.allowLegacyDefault)
|
|
106
|
+
return fail('AUTH_CONTEXT_MISSING');
|
|
107
|
+
const candidates = (input.memberships ?? []).filter((membership) => (membership.membershipStatus === undefined || membership.membershipStatus === 'ACTIVE')
|
|
108
|
+
&& (membership.isDefault === true || membership.isDefault === 1));
|
|
109
|
+
const accessible = candidates.filter((membership) => membership.appAccess !== false);
|
|
110
|
+
if (accessible.length !== 1)
|
|
111
|
+
return fail('TENANT_CONTEXT_INVALID');
|
|
112
|
+
return ok({
|
|
113
|
+
selectedTenant: { id: accessible[0].tenantId, membershipId: accessible[0].membershipId },
|
|
114
|
+
source: 'legacy-is-default',
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
function createMemoryStore() {
|
|
118
|
+
let tokens = null;
|
|
119
|
+
return {
|
|
120
|
+
get: () => tokens,
|
|
121
|
+
set: (next) => { tokens = next; },
|
|
122
|
+
clear: () => { tokens = null; },
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function responseError(status) {
|
|
126
|
+
if (status === 401)
|
|
127
|
+
return fail('INVALID_CREDENTIALS');
|
|
128
|
+
if (status === 403)
|
|
129
|
+
return fail('APP_ACCESS_DENIED');
|
|
130
|
+
if (status >= 500)
|
|
131
|
+
return fail('AUTH_PROTOCOL_UNAVAILABLE', true);
|
|
132
|
+
return fail('AUTH_PROTOCOL_UNAVAILABLE', status >= 408);
|
|
133
|
+
}
|
|
134
|
+
export function create(config) {
|
|
135
|
+
const fetchImpl = config.fetchImpl ?? fetch;
|
|
136
|
+
const tokenStore = config.tokenStore ?? createMemoryStore();
|
|
137
|
+
const authDomain = config.authDomain.replace(/\/$/, '');
|
|
138
|
+
let refreshPending = null;
|
|
139
|
+
function validate() {
|
|
140
|
+
if (!config.authDomain)
|
|
141
|
+
return fail('AUTH_DOMAIN_MISSING');
|
|
142
|
+
if (!parseHttpUrl(config.authDomain, config.runtime === 'development' || config.runtime === 'test'))
|
|
143
|
+
return fail('AUTH_DOMAIN_INVALID');
|
|
144
|
+
return ok(undefined);
|
|
145
|
+
}
|
|
146
|
+
function buildAuthorizeUrl(options) {
|
|
147
|
+
const valid = validate();
|
|
148
|
+
if (!valid.ok)
|
|
149
|
+
return valid;
|
|
150
|
+
const callbackUrl = parseHttpUrl(options.callbackUrl, config.runtime === 'development' || config.runtime === 'test');
|
|
151
|
+
if (!callbackUrl)
|
|
152
|
+
return fail('CALLBACK_URL_INVALID');
|
|
153
|
+
const allowedOrigins = config.allowedCallbackOrigins ?? [];
|
|
154
|
+
if (allowedOrigins.length > 0 && !allowedOrigins.includes(callbackUrl.origin))
|
|
155
|
+
return fail('CALLBACK_ORIGIN_DENIED');
|
|
156
|
+
const url = new URL('/auth/login', `${authDomain}/`);
|
|
157
|
+
url.searchParams.set('app', options.appCode ?? config.appCode ?? '');
|
|
158
|
+
url.searchParams.set('client_id', options.clientId ?? config.clientId ?? '');
|
|
159
|
+
url.searchParams.set('callback_url', callbackUrl.toString());
|
|
160
|
+
url.searchParams.set('return_to', callbackUrl.toString());
|
|
161
|
+
if (options.state)
|
|
162
|
+
url.searchParams.set('state', options.state);
|
|
163
|
+
return ok(url.toString());
|
|
164
|
+
}
|
|
165
|
+
async function login(username, password) {
|
|
166
|
+
const valid = validate();
|
|
167
|
+
if (!valid.ok)
|
|
168
|
+
return valid;
|
|
169
|
+
if (!username || !password)
|
|
170
|
+
return fail('INVALID_CREDENTIALS');
|
|
171
|
+
try {
|
|
172
|
+
const response = await fetchImpl(`${authDomain}/api/v1/auth/token`, {
|
|
173
|
+
method: 'POST',
|
|
174
|
+
headers: { 'Content-Type': 'application/json' },
|
|
175
|
+
body: JSON.stringify({ username, password }),
|
|
176
|
+
});
|
|
177
|
+
if (!response.ok)
|
|
178
|
+
return responseError(response.status);
|
|
179
|
+
const body = await response.json();
|
|
180
|
+
const tokens = toTokenSet(body);
|
|
181
|
+
if (!tokens)
|
|
182
|
+
return fail('AUTH_PROTOCOL_UNAVAILABLE');
|
|
183
|
+
tokenStore.set(tokens);
|
|
184
|
+
const context = isRecord(body) ? normalizeContext(body.context ?? body.auth_context) ?? undefined : undefined;
|
|
185
|
+
return ok({ ...tokens, context, identity: normalizeIdentity(body) ?? undefined });
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return fail('AUTH_PROTOCOL_UNAVAILABLE', true);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
async function runRefresh() {
|
|
192
|
+
const current = tokenStore.get();
|
|
193
|
+
if (!current?.refreshToken)
|
|
194
|
+
return fail('REFRESH_FAILED');
|
|
195
|
+
try {
|
|
196
|
+
const response = await fetchImpl(`${authDomain}/api/v1/auth/refresh`, {
|
|
197
|
+
method: 'POST',
|
|
198
|
+
headers: { 'Content-Type': 'application/json' },
|
|
199
|
+
body: JSON.stringify({ refresh_token: current.refreshToken }),
|
|
200
|
+
});
|
|
201
|
+
if (!response.ok) {
|
|
202
|
+
tokenStore.clear();
|
|
203
|
+
return response.status === 401 ? fail('TOKEN_EXPIRED') : responseError(response.status);
|
|
204
|
+
}
|
|
205
|
+
const body = await response.json();
|
|
206
|
+
const tokens = toTokenSet(body);
|
|
207
|
+
if (!tokens) {
|
|
208
|
+
tokenStore.clear();
|
|
209
|
+
return fail('AUTH_PROTOCOL_UNAVAILABLE');
|
|
210
|
+
}
|
|
211
|
+
tokenStore.set(tokens);
|
|
212
|
+
return ok({ ...tokens, context: isRecord(body) ? normalizeContext(body.context ?? body.auth_context) ?? undefined : undefined, identity: normalizeIdentity(body) ?? undefined });
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
tokenStore.clear();
|
|
216
|
+
return fail('REFRESH_FAILED', true);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function refreshToken() {
|
|
220
|
+
if (!refreshPending)
|
|
221
|
+
refreshPending = runRefresh().finally(() => { refreshPending = null; });
|
|
222
|
+
return refreshPending;
|
|
223
|
+
}
|
|
224
|
+
async function getContext() {
|
|
225
|
+
const current = tokenStore.get();
|
|
226
|
+
if (!current?.accessToken)
|
|
227
|
+
return fail('LOGIN_REQUIRED');
|
|
228
|
+
try {
|
|
229
|
+
const response = await fetchImpl(`${authDomain}/api/v1/auth/me`, { headers: { Authorization: `Bearer ${current.accessToken}` } });
|
|
230
|
+
if (!response.ok)
|
|
231
|
+
return response.status === 401 ? fail('TOKEN_EXPIRED') : responseError(response.status);
|
|
232
|
+
const context = normalizeContext(await response.json());
|
|
233
|
+
if (!context)
|
|
234
|
+
return fail('AUTH_CONTEXT_MISSING');
|
|
235
|
+
if (!context.appAccess?.allowed)
|
|
236
|
+
return fail('APP_ACCESS_DENIED');
|
|
237
|
+
return ok(context);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return fail('AUTH_PROTOCOL_UNAVAILABLE', true);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async function getIdentity() {
|
|
244
|
+
const current = tokenStore.get();
|
|
245
|
+
if (!current?.accessToken)
|
|
246
|
+
return fail('LOGIN_REQUIRED');
|
|
247
|
+
try {
|
|
248
|
+
const response = await fetchImpl(`${authDomain}/api/v1/auth/me`, { headers: { Authorization: `Bearer ${current.accessToken}` } });
|
|
249
|
+
if (!response.ok)
|
|
250
|
+
return response.status === 401 ? fail('TOKEN_EXPIRED') : responseError(response.status);
|
|
251
|
+
const identity = normalizeIdentity(await response.json());
|
|
252
|
+
return identity ? ok(identity) : fail('AUTH_PROTOCOL_UNAVAILABLE');
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return fail('AUTH_PROTOCOL_UNAVAILABLE', true);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
async function logout() {
|
|
259
|
+
tokenStore.clear();
|
|
260
|
+
return ok(undefined);
|
|
261
|
+
}
|
|
262
|
+
return { validate, buildAuthorizeUrl, login, refreshToken, getIdentity, getContext, logout };
|
|
263
|
+
}
|
|
264
|
+
/** 创建旧版兼容实例;新业务接入请使用 create。 */
|
|
265
|
+
export function createAuthKit(config) {
|
|
266
|
+
const callbackUrl = parseHttpUrl(config.callbackUrl);
|
|
267
|
+
const configuredEntry = config.loginEntryUrl ?? config.runtimeLoginEntryUrl ?? config.buildLoginEntryUrl ?? config.developmentLoginEntryUrl;
|
|
268
|
+
const entryUrl = configuredEntry ? parseHttpUrl(configuredEntry) : null;
|
|
269
|
+
const allowedOrigins = config.allowedLoginOrigins ?? (entryUrl ? [entryUrl.origin] : []);
|
|
270
|
+
function validateConfiguration() {
|
|
271
|
+
if (!configuredEntry)
|
|
272
|
+
return fail('LOGIN_ENTRY_URL_MISSING');
|
|
273
|
+
if (!entryUrl || !callbackUrl)
|
|
274
|
+
return fail('INVALID_LOGIN_ENTRY_URL');
|
|
275
|
+
if (!allowedOrigins.includes(entryUrl.origin))
|
|
276
|
+
return fail('LOGIN_ENTRY_ORIGIN_DENIED');
|
|
277
|
+
return ok({ loginEntryUrl: entryUrl.toString() });
|
|
278
|
+
}
|
|
279
|
+
function buildAuthorizeUrl(requestedTenantId) {
|
|
280
|
+
const valid = validateConfiguration();
|
|
281
|
+
if (!valid.ok)
|
|
282
|
+
return valid;
|
|
283
|
+
if (config.isLoginHost)
|
|
284
|
+
return fail('AUTH_ENTRY_CALLBACK_LOOP');
|
|
285
|
+
const url = new URL(valid.value.loginEntryUrl);
|
|
286
|
+
url.searchParams.set('client_id', config.clientId);
|
|
287
|
+
url.searchParams.set('app', config.appCode);
|
|
288
|
+
url.searchParams.set('return_to', callbackUrl.toString());
|
|
289
|
+
if (requestedTenantId)
|
|
290
|
+
url.searchParams.set('requested_tenant_id', requestedTenantId);
|
|
291
|
+
return ok({ url: url.toString() });
|
|
292
|
+
}
|
|
293
|
+
function decideDestination(context) {
|
|
294
|
+
if (!context.clientId || context.clientId !== config.clientId || !context.appCode || context.appCode !== config.appCode || !context.tenantId || !context.membershipId)
|
|
295
|
+
return fail('IDENTITY_CONTEXT_MISSING');
|
|
296
|
+
const platformUrl = context.tenantPlatformUrl ? parseHttpUrl(context.tenantPlatformUrl) : null;
|
|
297
|
+
if (!platformUrl)
|
|
298
|
+
return fail('IDENTITY_CONTEXT_MISSING');
|
|
299
|
+
if (context.principalKind === 'PLATFORM_ADMIN')
|
|
300
|
+
return ok({ url: platformUrl.toString(), ignoredCallback: Boolean(context.callbackUrl) });
|
|
301
|
+
const callback = context.callbackUrl ? parseHttpUrl(context.callbackUrl) : null;
|
|
302
|
+
if (callback && callback.origin === callbackUrl?.origin)
|
|
303
|
+
return ok({ url: callback.toString(), ignoredCallback: false });
|
|
304
|
+
return ok({ url: platformUrl.toString(), ignoredCallback: false });
|
|
305
|
+
}
|
|
306
|
+
async function applySession(result, adapter) {
|
|
307
|
+
if (adapter)
|
|
308
|
+
await adapter.set(result);
|
|
309
|
+
return result;
|
|
310
|
+
}
|
|
311
|
+
return { validateConfiguration, buildAuthorizeUrl, decideDestination, applySession };
|
|
312
|
+
}
|
|
313
|
+
/** 同一标签页只保留一个刷新请求;传输和存储仍由宿主负责。 */
|
|
314
|
+
export function createRefreshCoordinator() {
|
|
315
|
+
let pending = null;
|
|
316
|
+
return {
|
|
317
|
+
refresh(task) {
|
|
318
|
+
if (!pending)
|
|
319
|
+
pending = task().finally(() => { pending = null; });
|
|
320
|
+
return pending;
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@msdongcha/auth-kit",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.1",
|
|
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
|
+
}
|