@xfe-repo/cli-plugin-oauth 2.0.8
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 +9 -0
- package/dist/callback-runner.d.ts +8 -0
- package/dist/callback-runner.js +26 -0
- package/dist/callback-server.d.ts +11 -0
- package/dist/callback-server.js +74 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +427 -0
- package/dist/types.d.ts +51 -0
- package/dist/types.js +12 -0
- package/package.json +33 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @xfe-repo/cli-plugin-oauth - OAuth 回调子进程
|
|
3
|
+
*
|
|
4
|
+
* 在独立 Node 进程中等待本地 OAuth 回调,让 CLI session 的 Ctrl+C
|
|
5
|
+
* 可以通过 ctx.exec 终止等待中的回调服务。
|
|
6
|
+
*/
|
|
7
|
+
import { OAUTH_CALLBACK_URL_PREFIX, startOAuthCallbackServer } from './callback-server.js';
|
|
8
|
+
async function main() {
|
|
9
|
+
const redirectUri = process.argv[2];
|
|
10
|
+
if (!redirectUri)
|
|
11
|
+
throw new Error('缺少 OAuth redirectUri 参数');
|
|
12
|
+
const callbackServer = await startOAuthCallbackServer(redirectUri);
|
|
13
|
+
try {
|
|
14
|
+
const callbackUrl = await callbackServer.waitForCallback();
|
|
15
|
+
process.stdout.write(`${OAUTH_CALLBACK_URL_PREFIX}${callbackUrl.href}\n`);
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
await callbackServer.close();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
main().catch((error) => {
|
|
22
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
23
|
+
process.stderr.write(`${message}\n`);
|
|
24
|
+
process.exitCode = 1;
|
|
25
|
+
});
|
|
26
|
+
//# sourceMappingURL=callback-runner.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @xfe-repo/cli-plugin-oauth - OAuth 回调服务
|
|
3
|
+
*
|
|
4
|
+
* 基于 Hono 启动临时本地 HTTP 服务,等待 OAuth 授权服务器回跳 redirectUri。
|
|
5
|
+
*/
|
|
6
|
+
export interface OAuthCallbackServer {
|
|
7
|
+
waitForCallback(): Promise<URL>;
|
|
8
|
+
close(): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
export declare function startOAuthCallbackServer(redirectUri: string): Promise<OAuthCallbackServer>;
|
|
11
|
+
//# sourceMappingURL=callback-server.d.ts.map
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @xfe-repo/cli-plugin-oauth - OAuth 回调服务
|
|
3
|
+
*
|
|
4
|
+
* 基于 Hono 启动临时本地 HTTP 服务,等待 OAuth 授权服务器回跳 redirectUri。
|
|
5
|
+
*/
|
|
6
|
+
import { serve } from '@hono/node-server';
|
|
7
|
+
import { Hono } from 'hono';
|
|
8
|
+
// ─── Constants ─────────────────────────────────────────────
|
|
9
|
+
const CALLBACK_TIMEOUT_MS = 300000;
|
|
10
|
+
const HTTP_OK = 200;
|
|
11
|
+
const HTTP_BAD_REQUEST = 400;
|
|
12
|
+
const HTTP_NOT_FOUND = 404;
|
|
13
|
+
const SUCCESS_HTML = '<!doctype html><html lang="zh"><body>OAuth 登录完成,可以关闭此窗口。</body></html>';
|
|
14
|
+
const FAILURE_HTML = '<!doctype html><html lang="zh"><body>OAuth 登录失败,请回到终端查看错误。</body></html>';
|
|
15
|
+
// ─── Public API ────────────────────────────────────────────
|
|
16
|
+
export async function startOAuthCallbackServer(redirectUri) {
|
|
17
|
+
const redirectUrl = new URL(redirectUri);
|
|
18
|
+
const port = Number.parseInt(redirectUrl.port, 10);
|
|
19
|
+
if (redirectUrl.protocol !== 'http:') {
|
|
20
|
+
throw new Error('oauth.redirectUri 仅支持本地 http 回调地址');
|
|
21
|
+
}
|
|
22
|
+
if (redirectUrl.hostname !== '127.0.0.1' && redirectUrl.hostname !== 'localhost') {
|
|
23
|
+
throw new Error('oauth.redirectUri 必须使用 127.0.0.1 或 localhost');
|
|
24
|
+
}
|
|
25
|
+
if (!redirectUrl.port || Number.isNaN(port)) {
|
|
26
|
+
throw new Error('oauth.redirectUri 必须包含有效端口号');
|
|
27
|
+
}
|
|
28
|
+
let resolveCallback = () => { };
|
|
29
|
+
let rejectCallback = () => { };
|
|
30
|
+
let hasClosed = false;
|
|
31
|
+
const callbackPromise = new Promise((resolve, reject) => {
|
|
32
|
+
resolveCallback = resolve;
|
|
33
|
+
rejectCallback = reject;
|
|
34
|
+
});
|
|
35
|
+
const app = new Hono();
|
|
36
|
+
app.get(redirectUrl.pathname, (ctx) => {
|
|
37
|
+
const currentUrl = new URL(ctx.req.url);
|
|
38
|
+
const error = currentUrl.searchParams.get('error');
|
|
39
|
+
if (error) {
|
|
40
|
+
const description = currentUrl.searchParams.get('error_description');
|
|
41
|
+
rejectCallback(new Error(description ? `${error}: ${description}` : error));
|
|
42
|
+
return ctx.html(FAILURE_HTML, HTTP_BAD_REQUEST);
|
|
43
|
+
}
|
|
44
|
+
resolveCallback(currentUrl);
|
|
45
|
+
return ctx.html(SUCCESS_HTML, HTTP_OK);
|
|
46
|
+
});
|
|
47
|
+
app.notFound((ctx) => ctx.html(FAILURE_HTML, HTTP_NOT_FOUND));
|
|
48
|
+
const httpServer = serve({
|
|
49
|
+
fetch: app.fetch,
|
|
50
|
+
hostname: redirectUrl.hostname,
|
|
51
|
+
port,
|
|
52
|
+
});
|
|
53
|
+
const timeout = setTimeout(() => {
|
|
54
|
+
rejectCallback(new Error('OAuth 登录超时,请重新执行 oauth:login'));
|
|
55
|
+
}, CALLBACK_TIMEOUT_MS);
|
|
56
|
+
return {
|
|
57
|
+
waitForCallback: () => callbackPromise,
|
|
58
|
+
close: async () => {
|
|
59
|
+
if (hasClosed)
|
|
60
|
+
return;
|
|
61
|
+
hasClosed = true;
|
|
62
|
+
clearTimeout(timeout);
|
|
63
|
+
rejectCallback(new Error('OAuth 回调服务已关闭'));
|
|
64
|
+
const closableServer = httpServer;
|
|
65
|
+
await new Promise((resolve) => {
|
|
66
|
+
httpServer.close(() => {
|
|
67
|
+
resolve();
|
|
68
|
+
});
|
|
69
|
+
closableServer.closeAllConnections?.();
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=callback-server.js.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @xfe-repo/cli-plugin-oauth - OAuth 登录插件
|
|
3
|
+
*
|
|
4
|
+
* 通过 openid-client 对接 OIDC/OAuth 登录,保存本地 token 状态,
|
|
5
|
+
* 并向其他插件提供 auth/login/getToken/logout 能力。
|
|
6
|
+
*/
|
|
7
|
+
import type { XfePlugin } from '@xfe-repo/cli-core';
|
|
8
|
+
import { type OAuthCapability, type OAuthConfig, type OAuthStoreState, type OAuthTokenState, type OAuthUserState } from './types.js';
|
|
9
|
+
export type { OAuthCapability, OAuthConfig, OAuthLoginOptions, OAuthSessionState, OAuthStoreState, OAuthTokenState, OAuthUserState, } from './types.js';
|
|
10
|
+
export { OAuthClientAuthMethod } from './types.js';
|
|
11
|
+
export declare const oauthStoreInitial: OAuthStoreState;
|
|
12
|
+
declare module '@xfe-repo/cli-core' {
|
|
13
|
+
interface XfePluginRegistry {
|
|
14
|
+
oauth: XfePlugin<typeof oauthStoreInitial, OAuthCapability>;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export declare function oauthPlugin(): XfePlugin<typeof oauthStoreInitial, OAuthCapability>;
|
|
18
|
+
export default oauthPlugin;
|
|
19
|
+
export declare function resolveOAuthStateForConfig(config: OAuthConfig, state?: Partial<OAuthStoreState>): OAuthTokenState;
|
|
20
|
+
export declare function resolveOAuthConfigKey(config: OAuthConfig): string;
|
|
21
|
+
export declare function normalizeOAuthUserInfo(input: unknown): OAuthUserState;
|
|
22
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @xfe-repo/cli-plugin-oauth - OAuth 登录插件
|
|
3
|
+
*
|
|
4
|
+
* 通过 openid-client 对接 OIDC/OAuth 登录,保存本地 token 状态,
|
|
5
|
+
* 并向其他插件提供 auth/login/getToken/logout 能力。
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import * as oauthClient from 'openid-client';
|
|
9
|
+
import open from 'open';
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import { OAuthClientAuthMethod, } from './types.js';
|
|
12
|
+
import { startOAuthCallbackServer } from './callback-server.js';
|
|
13
|
+
export { OAuthClientAuthMethod } from './types.js';
|
|
14
|
+
// ─── Constants ─────────────────────────────────────────────
|
|
15
|
+
const OAUTH_PLUGIN_NAME = 'oauth';
|
|
16
|
+
const DEFAULT_OAUTH_NAME = 'OAuth';
|
|
17
|
+
const DEFAULT_SCOPE = 'openid profile email';
|
|
18
|
+
const DEFAULT_REDIRECT_URI = 'http://127.0.0.1:38080/oauth/callback';
|
|
19
|
+
const DEFAULT_TOKEN_TYPE = 'Bearer';
|
|
20
|
+
const TOKEN_EXPIRY_SKEW_SECONDS = 60;
|
|
21
|
+
const RESPONSE_TYPE_CODE = 'code';
|
|
22
|
+
const MILLISECONDS_PER_SECOND = 1000;
|
|
23
|
+
const OAUTH_CONFIG_KEY_PREFIX = 'oauth';
|
|
24
|
+
const OAUTH_CONFIG_KEY_VERSION = 'v1';
|
|
25
|
+
const OAUTH_CONFIG_KEY_SEPARATOR = '\n';
|
|
26
|
+
const OAUTH_CONFIG_HASH_ALGORITHM = 'sha256';
|
|
27
|
+
const OAUTH_CONFIG_HASH_ENCODING = 'hex';
|
|
28
|
+
export const oauthStoreInitial = {};
|
|
29
|
+
const oauthConfigSchema = z
|
|
30
|
+
.object({
|
|
31
|
+
name: z.string().trim().min(1).default(DEFAULT_OAUTH_NAME),
|
|
32
|
+
discoveryUrl: z.string().trim().url().optional(),
|
|
33
|
+
authorizeUrl: z.string().trim().url().optional(),
|
|
34
|
+
tokenUrl: z.string().trim().url().optional(),
|
|
35
|
+
userInfoUrl: z.string().trim().url().optional(),
|
|
36
|
+
clientId: z.string().trim().min(1),
|
|
37
|
+
clientSecret: z.string().trim().min(1),
|
|
38
|
+
scope: z.string().trim().min(1).default(DEFAULT_SCOPE),
|
|
39
|
+
redirectUri: z.string().trim().url().default(DEFAULT_REDIRECT_URI),
|
|
40
|
+
clientAuthMethod: z.enum([OAuthClientAuthMethod.ClientSecretPost, OAuthClientAuthMethod.ClientSecretBasic]).default(OAuthClientAuthMethod.ClientSecretBasic),
|
|
41
|
+
})
|
|
42
|
+
.refine((config) => config.discoveryUrl || (config.authorizeUrl && config.tokenUrl), {
|
|
43
|
+
message: '需要配置 discoveryUrl,或配置 authorizeUrl + tokenUrl',
|
|
44
|
+
path: ['discoveryUrl'],
|
|
45
|
+
});
|
|
46
|
+
// ─── Plugin ────────────────────────────────────────────────
|
|
47
|
+
export function oauthPlugin() {
|
|
48
|
+
return {
|
|
49
|
+
name: OAUTH_PLUGIN_NAME,
|
|
50
|
+
setup(_ctx, hooks) {
|
|
51
|
+
const runtime = {
|
|
52
|
+
callbackServers: new Set(),
|
|
53
|
+
isCleaningUp: false,
|
|
54
|
+
};
|
|
55
|
+
hooks.registerStore(oauthStoreInitial, { scope: 'global' });
|
|
56
|
+
hooks.registerView({
|
|
57
|
+
name: 'oauth-status',
|
|
58
|
+
label: 'OAuth',
|
|
59
|
+
slot: ['footer'],
|
|
60
|
+
value: (state) => {
|
|
61
|
+
const currentSession = resolveOptionalOAuthState(state.xfe, state.oauth);
|
|
62
|
+
return currentSession ? `Oauth ${currentSession.isAuthenticated ? '已登录' : '未登录'}` : undefined;
|
|
63
|
+
},
|
|
64
|
+
color: (state) => {
|
|
65
|
+
const currentSession = resolveOptionalOAuthState(state.xfe, state.oauth);
|
|
66
|
+
return currentSession?.isAuthenticated ? 'cyan' : 'yellow';
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
hooks.onProjectInit.tap(OAUTH_PLUGIN_NAME, async (ctx) => {
|
|
70
|
+
const config = parseOptionalOAuthConfig(ctx.store.getState().xfe);
|
|
71
|
+
if (!config)
|
|
72
|
+
return;
|
|
73
|
+
replaceOAuthStoreState(ctx, createOAuthStoreState(ctx.store.getState().oauth));
|
|
74
|
+
});
|
|
75
|
+
hooks.registerCommand({
|
|
76
|
+
name: 'oauth:login',
|
|
77
|
+
simpleDescription: '登录 OAuth 平台',
|
|
78
|
+
description: '通过 xfe.json 中的 oauth 配置打开浏览器登录,并将 token 保存到本地状态,供其他插件复用。',
|
|
79
|
+
execute: async (ctx) => {
|
|
80
|
+
await login(ctx, { force: true }, runtime);
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
hooks.registerCommand({
|
|
84
|
+
name: 'oauth:logout',
|
|
85
|
+
simpleDescription: '退出 OAuth 平台',
|
|
86
|
+
description: '清除本地保存的 OAuth token 状态,不修改 xfe.json 中的 OAuth 配置。',
|
|
87
|
+
execute: async (ctx) => {
|
|
88
|
+
await logout(ctx);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
hooks.provide('oauth', {
|
|
92
|
+
auth: (ctx, options) => login(ctx, options, runtime),
|
|
93
|
+
login: (ctx, options) => login(ctx, options, runtime),
|
|
94
|
+
getToken: (ctx) => getToken(ctx, runtime),
|
|
95
|
+
getUser: (ctx) => getUser(ctx, runtime),
|
|
96
|
+
logout,
|
|
97
|
+
});
|
|
98
|
+
hooks.onCleanup.tap(OAUTH_PLUGIN_NAME, async () => {
|
|
99
|
+
runtime.isCleaningUp = true;
|
|
100
|
+
const callbackServers = [...runtime.callbackServers];
|
|
101
|
+
runtime.callbackServers.clear();
|
|
102
|
+
await Promise.allSettled(callbackServers.map((callbackServer) => callbackServer.close()));
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
export default oauthPlugin;
|
|
108
|
+
// ─── Capability Handlers ───────────────────────────────────
|
|
109
|
+
async function login(ctx, options, runtime) {
|
|
110
|
+
const config = parseOAuthConfig(ctx.store.getState().xfe);
|
|
111
|
+
const current = activateOAuthState(ctx, config);
|
|
112
|
+
const now = Math.floor(Date.now() / MILLISECONDS_PER_SECOND);
|
|
113
|
+
if (!options?.force &&
|
|
114
|
+
current.isAuthenticated &&
|
|
115
|
+
current.accessToken &&
|
|
116
|
+
(!current.expiresAt || current.expiresAt - TOKEN_EXPIRY_SKEW_SECONDS > now)) {
|
|
117
|
+
return current;
|
|
118
|
+
}
|
|
119
|
+
const spinner = ctx.spinner.create(`正在登录 ${config.name}...`);
|
|
120
|
+
spinner.start();
|
|
121
|
+
try {
|
|
122
|
+
const clientConfig = await discoverOAuthConfig(config);
|
|
123
|
+
const callbackServer = await startOAuthCallbackServer(config.redirectUri);
|
|
124
|
+
if (runtime.isCleaningUp) {
|
|
125
|
+
await callbackServer.close();
|
|
126
|
+
throw new Error('OAuth 插件正在退出,已取消登录');
|
|
127
|
+
}
|
|
128
|
+
const codeVerifier = oauthClient.randomPKCECodeVerifier();
|
|
129
|
+
const codeChallenge = await oauthClient.calculatePKCECodeChallenge(codeVerifier);
|
|
130
|
+
const state = oauthClient.randomState();
|
|
131
|
+
const authorizationUrl = oauthClient.buildAuthorizationUrl(clientConfig, {
|
|
132
|
+
redirect_uri: config.redirectUri,
|
|
133
|
+
scope: config.scope,
|
|
134
|
+
code_challenge: codeChallenge,
|
|
135
|
+
code_challenge_method: 'S256',
|
|
136
|
+
state,
|
|
137
|
+
});
|
|
138
|
+
let tokenResponse;
|
|
139
|
+
runtime.callbackServers.add(callbackServer);
|
|
140
|
+
try {
|
|
141
|
+
await open(authorizationUrl.href);
|
|
142
|
+
const callbackUrl = await callbackServer.waitForCallback();
|
|
143
|
+
tokenResponse = await oauthClient.authorizationCodeGrant(clientConfig, callbackUrl, {
|
|
144
|
+
pkceCodeVerifier: codeVerifier,
|
|
145
|
+
expectedState: state,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
runtime.callbackServers.delete(callbackServer);
|
|
150
|
+
await callbackServer.close();
|
|
151
|
+
}
|
|
152
|
+
if (!tokenResponse.access_token)
|
|
153
|
+
throw new Error('OAuth token 响应缺少 access_token');
|
|
154
|
+
const expiresIn = tokenResponse.expiresIn();
|
|
155
|
+
const tokenState = {
|
|
156
|
+
name: config.name,
|
|
157
|
+
accessToken: tokenResponse.access_token,
|
|
158
|
+
refreshToken: tokenResponse.refresh_token || '',
|
|
159
|
+
idToken: tokenResponse.id_token || '',
|
|
160
|
+
tokenType: tokenResponse.token_type || DEFAULT_TOKEN_TYPE,
|
|
161
|
+
scope: tokenResponse.scope || '',
|
|
162
|
+
expiresAt: expiresIn ? now + expiresIn : 0,
|
|
163
|
+
isAuthenticated: true,
|
|
164
|
+
userId: 0,
|
|
165
|
+
userName: '',
|
|
166
|
+
};
|
|
167
|
+
saveOAuthSession(ctx, config, tokenState);
|
|
168
|
+
spinner.succeed(`${config.name} 登录成功`);
|
|
169
|
+
return resolveOAuthStateForConfig(config, ctx.store.getState().oauth);
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
spinner.fail(`${config.name} 登录失败`);
|
|
173
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
174
|
+
throw new Error(`OAuth 登录失败: ${message}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async function getToken(ctx, runtime) {
|
|
178
|
+
const config = parseOAuthConfig(ctx.store.getState().xfe);
|
|
179
|
+
const current = activateOAuthState(ctx, config);
|
|
180
|
+
const now = Math.floor(Date.now() / MILLISECONDS_PER_SECOND);
|
|
181
|
+
if (current.isAuthenticated && current.accessToken && (!current.expiresAt || current.expiresAt - TOKEN_EXPIRY_SKEW_SECONDS > now)) {
|
|
182
|
+
return current.accessToken;
|
|
183
|
+
}
|
|
184
|
+
if (current.refreshToken) {
|
|
185
|
+
try {
|
|
186
|
+
const clientConfig = await discoverOAuthConfig(config);
|
|
187
|
+
const tokenResponse = await oauthClient.refreshTokenGrant(clientConfig, current.refreshToken);
|
|
188
|
+
if (!tokenResponse.access_token)
|
|
189
|
+
throw new Error('OAuth token 响应缺少 access_token');
|
|
190
|
+
const expiresIn = tokenResponse.expiresIn();
|
|
191
|
+
const tokenState = {
|
|
192
|
+
name: config.name,
|
|
193
|
+
accessToken: tokenResponse.access_token,
|
|
194
|
+
refreshToken: tokenResponse.refresh_token || current.refreshToken,
|
|
195
|
+
idToken: tokenResponse.id_token || '',
|
|
196
|
+
tokenType: tokenResponse.token_type || DEFAULT_TOKEN_TYPE,
|
|
197
|
+
scope: tokenResponse.scope || '',
|
|
198
|
+
expiresAt: expiresIn ? now + expiresIn : 0,
|
|
199
|
+
isAuthenticated: true,
|
|
200
|
+
userId: 0,
|
|
201
|
+
userName: '',
|
|
202
|
+
};
|
|
203
|
+
saveOAuthSession(ctx, config, tokenState);
|
|
204
|
+
return tokenState.accessToken;
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
208
|
+
ctx.logger.warn(`OAuth token 刷新失败,将重新登录: ${message}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const tokenState = await login(ctx, undefined, runtime);
|
|
212
|
+
return tokenState.accessToken;
|
|
213
|
+
}
|
|
214
|
+
async function getUser(ctx, runtime) {
|
|
215
|
+
const config = parseOAuthConfig(ctx.store.getState().xfe);
|
|
216
|
+
const current = activateOAuthState(ctx, config);
|
|
217
|
+
if (current.userId > 0 && current.userName)
|
|
218
|
+
return { userId: current.userId, userName: current.userName };
|
|
219
|
+
if (!config.userInfoUrl)
|
|
220
|
+
throw new Error('xfe.json oauth 配置缺少 userInfoUrl,无法获取用户信息');
|
|
221
|
+
const accessToken = await getToken(ctx, runtime);
|
|
222
|
+
const latestBeforeUserInfo = resolveOAuthStateForConfig(config, ctx.store.getState().oauth);
|
|
223
|
+
const tokenType = latestBeforeUserInfo.tokenType || DEFAULT_TOKEN_TYPE;
|
|
224
|
+
const response = await fetch(config.userInfoUrl, {
|
|
225
|
+
headers: {
|
|
226
|
+
Authorization: `${tokenType} ${accessToken}`,
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
if (!response.ok) {
|
|
230
|
+
throw new Error(`OAuth 用户信息获取失败: HTTP ${response.status}`);
|
|
231
|
+
}
|
|
232
|
+
const user = normalizeOAuthUserInfo(await response.json());
|
|
233
|
+
const latest = resolveOAuthStateForConfig(config, ctx.store.getState().oauth);
|
|
234
|
+
saveOAuthSession(ctx, config, { ...toOAuthSessionState(latest, config.name), ...user });
|
|
235
|
+
return user;
|
|
236
|
+
}
|
|
237
|
+
async function logout(ctx) {
|
|
238
|
+
const config = parseOptionalOAuthConfig(ctx.store.getState().xfe);
|
|
239
|
+
const current = config ? resolveOAuthStateForConfig(config, ctx.store.getState().oauth) : undefined;
|
|
240
|
+
const name = config?.name || current?.name || DEFAULT_OAUTH_NAME;
|
|
241
|
+
const storeState = createOAuthStoreState(ctx.store.getState().oauth);
|
|
242
|
+
if (config)
|
|
243
|
+
delete storeState[resolveOAuthConfigKey(config)];
|
|
244
|
+
replaceOAuthStoreState(ctx, storeState);
|
|
245
|
+
ctx.logger.success(`${name} 已退出登录`);
|
|
246
|
+
}
|
|
247
|
+
// ─── Config And State ──────────────────────────────────────
|
|
248
|
+
export function resolveOAuthStateForConfig(config, state) {
|
|
249
|
+
const configKey = resolveOAuthConfigKey(config);
|
|
250
|
+
const session = state?.[configKey];
|
|
251
|
+
return session ? toOAuthSessionState(session, config.name) : createEmptyOAuthSession(config.name);
|
|
252
|
+
}
|
|
253
|
+
function parseOAuthConfig(xfeConfig) {
|
|
254
|
+
const config = parseOptionalOAuthConfig(xfeConfig);
|
|
255
|
+
if (!config)
|
|
256
|
+
throw new Error('xfe.json 中缺少 oauth 配置');
|
|
257
|
+
return config;
|
|
258
|
+
}
|
|
259
|
+
function parseOptionalOAuthConfig(xfeConfig) {
|
|
260
|
+
const rawConfig = xfeConfig?.oauth;
|
|
261
|
+
if (!rawConfig)
|
|
262
|
+
return undefined;
|
|
263
|
+
const result = oauthConfigSchema.safeParse(rawConfig);
|
|
264
|
+
if (!result.success) {
|
|
265
|
+
const message = result.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; ');
|
|
266
|
+
throw new Error(`xfe.json oauth 配置无效: ${message}`);
|
|
267
|
+
}
|
|
268
|
+
return result.data;
|
|
269
|
+
}
|
|
270
|
+
function activateOAuthState(ctx, config) {
|
|
271
|
+
const storeState = createOAuthStoreState(ctx.store.getState().oauth);
|
|
272
|
+
replaceOAuthStoreState(ctx, storeState);
|
|
273
|
+
return resolveOAuthStateForConfig(config, storeState);
|
|
274
|
+
}
|
|
275
|
+
function saveOAuthSession(ctx, config, session) {
|
|
276
|
+
const configKey = resolveOAuthConfigKey(config);
|
|
277
|
+
const storeState = createOAuthStoreState(ctx.store.getState().oauth);
|
|
278
|
+
storeState[configKey] = toOAuthSessionState(session, config.name);
|
|
279
|
+
replaceOAuthStoreState(ctx, storeState);
|
|
280
|
+
}
|
|
281
|
+
function replaceOAuthStoreState(ctx, state) {
|
|
282
|
+
ctx.store.setState({ oauth: state });
|
|
283
|
+
}
|
|
284
|
+
function resolveOptionalOAuthState(xfeConfig, state) {
|
|
285
|
+
const config = parseOptionalOAuthConfig(xfeConfig);
|
|
286
|
+
if (!config)
|
|
287
|
+
return undefined;
|
|
288
|
+
return resolveOAuthStateForConfig(config, state);
|
|
289
|
+
}
|
|
290
|
+
function createOAuthStoreState(state) {
|
|
291
|
+
return cloneOAuthState(state);
|
|
292
|
+
}
|
|
293
|
+
export function resolveOAuthConfigKey(config) {
|
|
294
|
+
const payload = [
|
|
295
|
+
OAUTH_CONFIG_KEY_VERSION,
|
|
296
|
+
config.discoveryUrl ?? '',
|
|
297
|
+
config.authorizeUrl ?? '',
|
|
298
|
+
config.tokenUrl ?? '',
|
|
299
|
+
config.userInfoUrl ?? '',
|
|
300
|
+
config.clientId,
|
|
301
|
+
config.scope,
|
|
302
|
+
config.redirectUri,
|
|
303
|
+
config.clientAuthMethod,
|
|
304
|
+
].join(OAUTH_CONFIG_KEY_SEPARATOR);
|
|
305
|
+
const digest = createHash(OAUTH_CONFIG_HASH_ALGORITHM).update(payload).digest(OAUTH_CONFIG_HASH_ENCODING);
|
|
306
|
+
return `${OAUTH_CONFIG_KEY_PREFIX}:${digest}`;
|
|
307
|
+
}
|
|
308
|
+
function cloneOAuthState(stateMap) {
|
|
309
|
+
if (!stateMap)
|
|
310
|
+
return {};
|
|
311
|
+
return Object.fromEntries(Object.entries(stateMap)
|
|
312
|
+
.filter((entry) => isOAuthSessionState(entry[1]))
|
|
313
|
+
.map(([configKey, session]) => [configKey, toOAuthSessionState(session, session.name)]));
|
|
314
|
+
}
|
|
315
|
+
function isOAuthSessionState(value) {
|
|
316
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
317
|
+
return false;
|
|
318
|
+
const record = value;
|
|
319
|
+
return typeof record.name === 'string';
|
|
320
|
+
}
|
|
321
|
+
function createEmptyOAuthSession(name) {
|
|
322
|
+
return toOAuthSessionState(undefined, name);
|
|
323
|
+
}
|
|
324
|
+
function toOAuthSessionState(state, name) {
|
|
325
|
+
return {
|
|
326
|
+
name: state?.name || name,
|
|
327
|
+
accessToken: state?.accessToken || '',
|
|
328
|
+
refreshToken: state?.refreshToken || '',
|
|
329
|
+
idToken: state?.idToken || '',
|
|
330
|
+
tokenType: state?.tokenType || DEFAULT_TOKEN_TYPE,
|
|
331
|
+
scope: state?.scope || '',
|
|
332
|
+
expiresAt: state?.expiresAt ?? 0,
|
|
333
|
+
isAuthenticated: state?.isAuthenticated ?? false,
|
|
334
|
+
userId: state?.userId ?? 0,
|
|
335
|
+
userName: state?.userName || '',
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
export function normalizeOAuthUserInfo(input) {
|
|
339
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
|
|
340
|
+
throw new Error('OAuth 用户信息响应格式无效');
|
|
341
|
+
}
|
|
342
|
+
const rootRecord = input;
|
|
343
|
+
const nestedRecord = [rootRecord.Data, rootRecord.data, rootRecord.result].find((value) => typeof value === 'object' && value !== null && !Array.isArray(value));
|
|
344
|
+
const userRecord = nestedRecord ?? rootRecord;
|
|
345
|
+
const userIdKeys = ['userId', 'user_id', 'UserId', 'id', 'uid'];
|
|
346
|
+
const userNameKeys = ['name', 'userName', 'user_name', 'UserName', 'username', 'nickName', 'nikeName', 'nickname'];
|
|
347
|
+
let userId = 0;
|
|
348
|
+
let userName = '';
|
|
349
|
+
for (const key of userIdKeys) {
|
|
350
|
+
const value = userRecord[key];
|
|
351
|
+
if (typeof value === 'number' && Number.isInteger(value)) {
|
|
352
|
+
userId = value;
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
if (typeof value === 'string' && value.trim()) {
|
|
356
|
+
const parsed = Number.parseInt(value, 10);
|
|
357
|
+
if (!Number.isNaN(parsed)) {
|
|
358
|
+
userId = parsed;
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (!userId)
|
|
364
|
+
throw new Error('OAuth 用户信息缺少 userId');
|
|
365
|
+
for (const key of userNameKeys) {
|
|
366
|
+
const value = userRecord[key];
|
|
367
|
+
if (typeof value === 'string' && value.trim()) {
|
|
368
|
+
userName = value.trim();
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (!userName)
|
|
373
|
+
throw new Error('OAuth 用户信息缺少 userName');
|
|
374
|
+
return { userId, userName };
|
|
375
|
+
}
|
|
376
|
+
// ─── OAuth Runtime ─────────────────────────────────────────
|
|
377
|
+
async function discoverOAuthConfig(config) {
|
|
378
|
+
const clientAuth = config.clientAuthMethod === OAuthClientAuthMethod.ClientSecretBasic
|
|
379
|
+
? oauthClient.ClientSecretBasic(config.clientSecret)
|
|
380
|
+
: oauthClient.ClientSecretPost(config.clientSecret);
|
|
381
|
+
const clientMetadata = {
|
|
382
|
+
client_secret: config.clientSecret,
|
|
383
|
+
token_endpoint_auth_method: config.clientAuthMethod,
|
|
384
|
+
};
|
|
385
|
+
if (config.discoveryUrl) {
|
|
386
|
+
return withTokenTypeFallback(await oauthClient.discovery(new URL(config.discoveryUrl), config.clientId, clientMetadata, clientAuth));
|
|
387
|
+
}
|
|
388
|
+
if (!config.authorizeUrl || !config.tokenUrl) {
|
|
389
|
+
throw new Error('oauth 显式端点模式需要配置 authorizeUrl、tokenUrl');
|
|
390
|
+
}
|
|
391
|
+
const serverMetadata = {
|
|
392
|
+
issuer: new URL(config.authorizeUrl).origin,
|
|
393
|
+
authorization_endpoint: config.authorizeUrl,
|
|
394
|
+
token_endpoint: config.tokenUrl,
|
|
395
|
+
response_types_supported: [RESPONSE_TYPE_CODE],
|
|
396
|
+
token_endpoint_auth_methods_supported: [config.clientAuthMethod],
|
|
397
|
+
};
|
|
398
|
+
return withTokenTypeFallback(new oauthClient.Configuration(config.userInfoUrl ? { ...serverMetadata, userinfo_endpoint: config.userInfoUrl } : serverMetadata, config.clientId, clientMetadata, clientAuth));
|
|
399
|
+
}
|
|
400
|
+
function withTokenTypeFallback(clientConfig) {
|
|
401
|
+
clientConfig[oauthClient.customFetch] = async (input, init) => {
|
|
402
|
+
const response = await fetch(input, init);
|
|
403
|
+
if (!(init?.body instanceof URLSearchParams) || !init.body.has('grant_type'))
|
|
404
|
+
return response;
|
|
405
|
+
if (!response.headers.get('content-type')?.includes('application/json'))
|
|
406
|
+
return response;
|
|
407
|
+
const body = await response
|
|
408
|
+
.clone()
|
|
409
|
+
.json()
|
|
410
|
+
.catch(() => undefined);
|
|
411
|
+
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
412
|
+
return response;
|
|
413
|
+
const tokenBody = body;
|
|
414
|
+
if (typeof tokenBody.access_token !== 'string' || tokenBody.token_type !== undefined)
|
|
415
|
+
return response;
|
|
416
|
+
const headers = new Headers(response.headers);
|
|
417
|
+
headers.delete('content-length');
|
|
418
|
+
headers.set('content-type', 'application/json');
|
|
419
|
+
return new Response(JSON.stringify({ ...tokenBody, token_type: DEFAULT_TOKEN_TYPE }), {
|
|
420
|
+
status: response.status,
|
|
421
|
+
statusText: response.statusText,
|
|
422
|
+
headers,
|
|
423
|
+
});
|
|
424
|
+
};
|
|
425
|
+
return clientConfig;
|
|
426
|
+
}
|
|
427
|
+
//# sourceMappingURL=index.js.map
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @xfe-repo/cli-plugin-oauth - 类型定义
|
|
3
|
+
*
|
|
4
|
+
* 定义 OAuth 配置、token 状态和插件对外能力接口。
|
|
5
|
+
*/
|
|
6
|
+
import type { PluginContext } from '@xfe-repo/cli-core';
|
|
7
|
+
export declare enum OAuthClientAuthMethod {
|
|
8
|
+
ClientSecretPost = "client_secret_post",
|
|
9
|
+
ClientSecretBasic = "client_secret_basic"
|
|
10
|
+
}
|
|
11
|
+
export interface OAuthConfig {
|
|
12
|
+
name: string;
|
|
13
|
+
discoveryUrl?: string;
|
|
14
|
+
authorizeUrl?: string;
|
|
15
|
+
tokenUrl?: string;
|
|
16
|
+
userInfoUrl?: string;
|
|
17
|
+
clientId: string;
|
|
18
|
+
clientSecret: string;
|
|
19
|
+
scope: string;
|
|
20
|
+
redirectUri: string;
|
|
21
|
+
clientAuthMethod: OAuthClientAuthMethod;
|
|
22
|
+
}
|
|
23
|
+
export interface OAuthSessionState {
|
|
24
|
+
name: string;
|
|
25
|
+
accessToken: string;
|
|
26
|
+
refreshToken: string;
|
|
27
|
+
idToken: string;
|
|
28
|
+
tokenType: string;
|
|
29
|
+
scope: string;
|
|
30
|
+
expiresAt: number;
|
|
31
|
+
isAuthenticated: boolean;
|
|
32
|
+
userId: number;
|
|
33
|
+
userName: string;
|
|
34
|
+
}
|
|
35
|
+
export type OAuthStoreState = Record<string, OAuthSessionState>;
|
|
36
|
+
export type OAuthTokenState = OAuthSessionState;
|
|
37
|
+
export interface OAuthLoginOptions {
|
|
38
|
+
force?: boolean;
|
|
39
|
+
}
|
|
40
|
+
export interface OAuthUserState {
|
|
41
|
+
userId: number;
|
|
42
|
+
userName: string;
|
|
43
|
+
}
|
|
44
|
+
export interface OAuthCapability {
|
|
45
|
+
auth(ctx: PluginContext, options?: OAuthLoginOptions): Promise<OAuthTokenState>;
|
|
46
|
+
login(ctx: PluginContext, options?: OAuthLoginOptions): Promise<OAuthTokenState>;
|
|
47
|
+
getToken(ctx: PluginContext): Promise<string>;
|
|
48
|
+
getUser(ctx: PluginContext): Promise<OAuthUserState>;
|
|
49
|
+
logout(ctx: PluginContext): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=types.d.ts.map
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @xfe-repo/cli-plugin-oauth - 类型定义
|
|
3
|
+
*
|
|
4
|
+
* 定义 OAuth 配置、token 状态和插件对外能力接口。
|
|
5
|
+
*/
|
|
6
|
+
// ─── Types ──────────────────────────────────────────────────
|
|
7
|
+
export var OAuthClientAuthMethod;
|
|
8
|
+
(function (OAuthClientAuthMethod) {
|
|
9
|
+
OAuthClientAuthMethod["ClientSecretPost"] = "client_secret_post";
|
|
10
|
+
OAuthClientAuthMethod["ClientSecretBasic"] = "client_secret_basic";
|
|
11
|
+
})(OAuthClientAuthMethod || (OAuthClientAuthMethod = {}));
|
|
12
|
+
//# sourceMappingURL=types.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xfe-repo/cli-plugin-oauth",
|
|
3
|
+
"version": "2.0.8",
|
|
4
|
+
"description": "XFE OAuth plugin - login through OIDC/OAuth and expose token state",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@hono/node-server": "^1.14.0",
|
|
10
|
+
"hono": "^4.7.0",
|
|
11
|
+
"openid-client": "^6.8.1",
|
|
12
|
+
"open": "^10.2.0",
|
|
13
|
+
"zod": "^4.3.6",
|
|
14
|
+
"@xfe-repo/cli-core": "2.1.0"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@types/node": "^24.3.0",
|
|
18
|
+
"typescript": "^5.9.2",
|
|
19
|
+
"vitest": "^3.1.0"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"CHANGELOG.md",
|
|
24
|
+
"!dist/**/*.map"
|
|
25
|
+
],
|
|
26
|
+
"license": "ISC",
|
|
27
|
+
"scripts": {
|
|
28
|
+
"dev": "tsc -p tsconfig.json -w",
|
|
29
|
+
"build": "tsc -p tsconfig.json",
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"clean": "rm -rf dist"
|
|
32
|
+
}
|
|
33
|
+
}
|