dsh-feishu-auth 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +125 -0
- package/README.md +90 -0
- package/cordis.patch.yml +10 -0
- package/disable.patch.yml +7 -0
- package/docs/architecture.md +154 -0
- package/docs/release.md +84 -0
- package/enable.patch.yml +9 -0
- package/lib/config.js +80 -0
- package/lib/feishu.js +129 -0
- package/lib/gate.js +493 -0
- package/lib/index.js +196 -0
- package/lib/pages.js +137 -0
- package/lib/session.js +206 -0
- package/lib/urls.js +94 -0
- package/package.json +62 -0
package/lib/feishu.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu (Lark) OAuth 2.0 authorization-code flow, server side.
|
|
3
|
+
*
|
|
4
|
+
* Endpoints are the current open-platform contract for a 网页应用 (web app):
|
|
5
|
+
* - authorize: GET https://accounts.feishu.cn/open-apis/authen/v1/authorize
|
|
6
|
+
* - token: POST https://accounts.feishu.cn/oauth/v3/token
|
|
7
|
+
* - user info: GET https://open.feishu.cn/open-apis/authen/v1/user_info
|
|
8
|
+
*
|
|
9
|
+
* The returned user access token is used once, to read the profile, and is
|
|
10
|
+
* never persisted: this plugin's own signed cookie is the session.
|
|
11
|
+
* @module dsh-feishu-auth/feishu
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const AUTHORIZE_ORIGIN = 'https://accounts.feishu.cn';
|
|
15
|
+
const TOKEN_ORIGIN = 'https://accounts.feishu.cn';
|
|
16
|
+
const OPEN_API_ORIGIN = 'https://open.feishu.cn';
|
|
17
|
+
const TIMEOUT_MS = 10000;
|
|
18
|
+
|
|
19
|
+
/** A Feishu API failure with the operator-facing detail already extracted. */
|
|
20
|
+
export class FeishuApiError extends Error {
|
|
21
|
+
/**
|
|
22
|
+
* @param message - sanitized description (never contains a token or secret).
|
|
23
|
+
* @param status - the HTTP status when the failure had one.
|
|
24
|
+
*/
|
|
25
|
+
constructor(message, status) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = 'FeishuApiError';
|
|
28
|
+
this.status = status;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build the authorization-page URL the browser is redirected to.
|
|
34
|
+
* @param options - app id, redirect URI, and the state nonce.
|
|
35
|
+
* @returns the absolute authorize URL.
|
|
36
|
+
*/
|
|
37
|
+
export function buildAuthorizeUrl({ clientId, redirectUri, state }) {
|
|
38
|
+
const url = new URL('/open-apis/authen/v1/authorize', AUTHORIZE_ORIGIN);
|
|
39
|
+
url.searchParams.set('client_id', clientId);
|
|
40
|
+
url.searchParams.set('response_type', 'code');
|
|
41
|
+
url.searchParams.set('redirect_uri', redirectUri);
|
|
42
|
+
url.searchParams.set('state', state);
|
|
43
|
+
return url.href;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function readJson(response, label) {
|
|
47
|
+
const text = await response.text();
|
|
48
|
+
try {
|
|
49
|
+
const json = JSON.parse(text);
|
|
50
|
+
if (json === null || typeof json !== 'object') throw new Error('not an object');
|
|
51
|
+
return json;
|
|
52
|
+
} catch {
|
|
53
|
+
throw new FeishuApiError(`${label}: 返回了非 JSON 响应 (HTTP ${String(response.status)})`, response.status);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Unwrap the platform's two response shapes: flat, or nested under `data`. */
|
|
58
|
+
function unwrap(json) {
|
|
59
|
+
return json.data !== null && typeof json.data === 'object' ? { ...json, ...json.data } : json;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Exchange an authorization code for a user access token.
|
|
64
|
+
*
|
|
65
|
+
* The `redirect_uri` must be byte-identical to the one used on the authorize
|
|
66
|
+
* request, which is why the caller replays the value stored in the state
|
|
67
|
+
* cookie rather than recomputing it.
|
|
68
|
+
* @param options - credentials, code, redirect URI, and a transport seam.
|
|
69
|
+
* @returns the user access token.
|
|
70
|
+
*/
|
|
71
|
+
export async function exchangeCode({ clientId, clientSecret, code, redirectUri, fetchImpl = globalThis.fetch }) {
|
|
72
|
+
const body = new URLSearchParams({
|
|
73
|
+
grant_type: 'authorization_code',
|
|
74
|
+
client_id: clientId,
|
|
75
|
+
client_secret: clientSecret,
|
|
76
|
+
code,
|
|
77
|
+
redirect_uri: redirectUri,
|
|
78
|
+
});
|
|
79
|
+
let response;
|
|
80
|
+
try {
|
|
81
|
+
response = await fetchImpl(new URL('/oauth/v3/token', TOKEN_ORIGIN), {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
84
|
+
body,
|
|
85
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
86
|
+
});
|
|
87
|
+
} catch (error) {
|
|
88
|
+
throw new FeishuApiError(`换取 user_access_token 失败: ${error?.message ?? String(error)}`);
|
|
89
|
+
}
|
|
90
|
+
const json = unwrap(await readJson(response, 'token 接口'));
|
|
91
|
+
if (typeof json.access_token !== 'string' || json.access_token === '') {
|
|
92
|
+
const detail = json.error_description ?? json.error ?? json.msg ?? `HTTP ${String(response.status)}`;
|
|
93
|
+
throw new FeishuApiError(`换取 user_access_token 被拒绝: ${String(detail)}`, response.status);
|
|
94
|
+
}
|
|
95
|
+
return { accessToken: json.access_token };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Read the authenticated user's profile with a user access token.
|
|
100
|
+
* @param options - token and a transport seam.
|
|
101
|
+
* @returns `open_id`, `union_id`, `user_id`, `name`, `tenant_key` (`open_id` and
|
|
102
|
+
* `tenant_key` need no extra permission).
|
|
103
|
+
*/
|
|
104
|
+
export async function fetchUserInfo({ accessToken, fetchImpl = globalThis.fetch }) {
|
|
105
|
+
let response;
|
|
106
|
+
try {
|
|
107
|
+
response = await fetchImpl(new URL('/open-apis/authen/v1/user_info', OPEN_API_ORIGIN), {
|
|
108
|
+
method: 'GET',
|
|
109
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
110
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
111
|
+
});
|
|
112
|
+
} catch (error) {
|
|
113
|
+
throw new FeishuApiError(`读取用户信息失败: ${error?.message ?? String(error)}`);
|
|
114
|
+
}
|
|
115
|
+
const json = unwrap(await readJson(response, 'user_info 接口'));
|
|
116
|
+
if (json.code !== 0 && json.code !== undefined) {
|
|
117
|
+
throw new FeishuApiError(`读取用户信息被拒绝: ${String(json.msg ?? json.code)}`, response.status);
|
|
118
|
+
}
|
|
119
|
+
if (typeof json.open_id !== 'string' || json.open_id === '') {
|
|
120
|
+
throw new FeishuApiError('用户信息缺少 open_id', response.status);
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
open_id: json.open_id,
|
|
124
|
+
union_id: typeof json.union_id === 'string' ? json.union_id : undefined,
|
|
125
|
+
user_id: typeof json.user_id === 'string' ? json.user_id : undefined,
|
|
126
|
+
name: typeof json.name === 'string' ? json.name : undefined,
|
|
127
|
+
tenant_key: typeof json.tenant_key === 'string' ? json.tenant_key : undefined,
|
|
128
|
+
};
|
|
129
|
+
}
|
package/lib/gate.js
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gate: one interception point in front of every HTTP request the harness
|
|
3
|
+
* web server would otherwise answer.
|
|
4
|
+
*
|
|
5
|
+
* The harness dispatches through `this.match(pathname)` and then hands the
|
|
6
|
+
* request to the matched route's handler, or to the fallback seat when nothing
|
|
7
|
+
* matched. Replacing that single method on the live service instance puts this
|
|
8
|
+
* plugin in front of named routes (`/api`, plugin bundles) AND the SPA
|
|
9
|
+
* fallback at once, on every address the server is bound to.
|
|
10
|
+
*
|
|
11
|
+
* `install()` therefore depends on one internal detail of
|
|
12
|
+
* `@deepseek-ai/dsh-host-webserver` (the dispatch read of `match`). It is
|
|
13
|
+
* checked at install time and reported loudly when absent. The WebSocket
|
|
14
|
+
* upgrade path is left alone: the harness's own mux already enforces its Host
|
|
15
|
+
* fence plus its signed browser cookie, which only browsers that came through
|
|
16
|
+
* this gate ever receive.
|
|
17
|
+
* @module dsh-feishu-auth/gate
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
HANDOFF_COOKIE,
|
|
22
|
+
HANDOFF_TTL_SECONDS,
|
|
23
|
+
PATH_PREFIX,
|
|
24
|
+
SESSION_COOKIE,
|
|
25
|
+
STATE_COOKIE,
|
|
26
|
+
STATE_TTL_MS,
|
|
27
|
+
} from './config.js';
|
|
28
|
+
import { buildAuthorizeUrl, exchangeCode, fetchUserInfo } from './feishu.js';
|
|
29
|
+
import { renderDenied, renderError, renderLoggedOut, renderMisconfigured } from './pages.js';
|
|
30
|
+
import { cookieNames, expiredCookie, generateToken, readCookie, safeEqual, serializeCookie, signPayload, verifyPayload } from './session.js';
|
|
31
|
+
import { clientAddress, isNavigationRequest, normalizeAuthority, requestBaseUrl, sanitizeNext } from './urls.js';
|
|
32
|
+
|
|
33
|
+
const LOGIN_PATH = '/login';
|
|
34
|
+
const CALLBACK_PATH = '/callback';
|
|
35
|
+
const LOGOUT_PATH = '/logout';
|
|
36
|
+
const STATUS_PATH = '/status';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Global symbol Cordis puts on a traceable proxy to expose the wrapped target, so
|
|
40
|
+
* the raw service instance can key per-server state without importing Cordis.
|
|
41
|
+
*/
|
|
42
|
+
const CORDIS_ORIGINAL = Symbol.for('cordis.original');
|
|
43
|
+
/** Marker key on an installed dispatcher: `{ gateMatch, original }`. */
|
|
44
|
+
export const DISPATCHER = Symbol.for('dsh-feishu-auth.dispatcher');
|
|
45
|
+
/** The dispatcher this module currently has installed, per raw web server. */
|
|
46
|
+
const installedByServer = new WeakMap();
|
|
47
|
+
|
|
48
|
+
function send(res, status, body, contentType) {
|
|
49
|
+
const text = String(body);
|
|
50
|
+
res.writeHead(status, {
|
|
51
|
+
'content-type': `${contentType}; charset=utf-8`,
|
|
52
|
+
'content-length': String(Buffer.byteLength(text)),
|
|
53
|
+
'cache-control': 'no-store',
|
|
54
|
+
});
|
|
55
|
+
res.end(text);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sendText(res, status, body, headers = {}) {
|
|
59
|
+
send(res, status, `${body}\n`, 'text/plain');
|
|
60
|
+
void headers;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function sendHtml(res, status, html, headers = {}) {
|
|
64
|
+
res.writeHead(status, {
|
|
65
|
+
'content-type': 'text/html; charset=utf-8',
|
|
66
|
+
'cache-control': 'no-store',
|
|
67
|
+
'referrer-policy': 'no-referrer',
|
|
68
|
+
'x-content-type-options': 'nosniff',
|
|
69
|
+
...headers,
|
|
70
|
+
});
|
|
71
|
+
res.end(html);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function redirect(res, status, location, headers = {}) {
|
|
75
|
+
res.writeHead(status, { location, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer', ...headers });
|
|
76
|
+
res.end();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Create the gate.
|
|
81
|
+
* @param options - resolved config, session secret, fatal config problems, the
|
|
82
|
+
* harness entry resolver, transport seams, and the logger.
|
|
83
|
+
* @returns the gate: `{ install, mode, prefix }`.
|
|
84
|
+
*/
|
|
85
|
+
export function createGate({ config, secret, fatalProblems = [], entryUrl, fetchImpl = globalThis.fetch, now = Date.now, logger }) {
|
|
86
|
+
const prefix = PATH_PREFIX;
|
|
87
|
+
const mode = fatalProblems.length > 0 ? 'misconfigured' : 'enforce';
|
|
88
|
+
const maxAgeMilliseconds = config.sessionMaxAgeDays * 24 * 60 * 60 * 1000;
|
|
89
|
+
const log = {
|
|
90
|
+
info: (message) => logger?.info?.(message),
|
|
91
|
+
warn: (message) => logger?.warn?.(message),
|
|
92
|
+
error: (message) => logger?.error?.(message),
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const isSecure = (baseUrl) => typeof baseUrl === 'string' && baseUrl.startsWith('https://');
|
|
96
|
+
const isOwnPath = (pathname) => pathname === prefix || pathname.startsWith(`${prefix}/`);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Decide whether a finished Feishu login may open the page.
|
|
100
|
+
*
|
|
101
|
+
* Feishu already answered the harder question — only a member inside the
|
|
102
|
+
* app's 可用范围 can complete the authorization at all. An empty
|
|
103
|
+
* `allowedUsers` therefore admits any such member; a non-empty one narrows
|
|
104
|
+
* further to the listed open_id / union_id / user_id values.
|
|
105
|
+
*/
|
|
106
|
+
function decideAccess(user) {
|
|
107
|
+
if (config.allowedUsers.length === 0) return { allowed: true };
|
|
108
|
+
const identities = [user.open_id, user.union_id, user.user_id].filter((value) => typeof value === 'string');
|
|
109
|
+
return identities.some((value) => config.allowedUsers.includes(value))
|
|
110
|
+
? { allowed: true }
|
|
111
|
+
: { allowed: false, reason: '该账号不在 allowedUsers 名单中' };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Resolve the caller's principal from the signed session cookie. */
|
|
115
|
+
function authenticate(req) {
|
|
116
|
+
const raw = readCookie(req?.headers?.cookie, SESSION_COOKIE);
|
|
117
|
+
const payload = verifyPayload(secret, raw);
|
|
118
|
+
if (payload === undefined || payload.kind !== 'session') {
|
|
119
|
+
return { ok: false, reason: raw === undefined ? 'no-cookie' : 'invalid-cookie' };
|
|
120
|
+
}
|
|
121
|
+
const nowMs = now();
|
|
122
|
+
if (typeof payload.exp !== 'number' || payload.exp <= nowMs) return { ok: false, reason: 'expired' };
|
|
123
|
+
if (typeof payload.iat === 'number' && payload.iat > nowMs + 60000) return { ok: false, reason: 'not-yet-valid' };
|
|
124
|
+
return { ok: true, user: payload };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Set once when the entry resolver is missing, so the failure is loud but not per-request spam. */
|
|
128
|
+
let warnedMissingEntry = false;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Where to send a just-authenticated browser.
|
|
132
|
+
*
|
|
133
|
+
* A Feishu session alone does not satisfy the harness: it mints its
|
|
134
|
+
* `dsh-auth-<authority>` cookie only for a root request carrying its
|
|
135
|
+
* per-process launch token. So the gate hands the browser to that exchange
|
|
136
|
+
* (`connection.authenticatedUrl`) and the harness redirects on to `/`.
|
|
137
|
+
* Without a resolver this degrades to the requested path, and the harness's
|
|
138
|
+
* own 401 page explains the remedy.
|
|
139
|
+
*/
|
|
140
|
+
function entryLocation(req, next) {
|
|
141
|
+
const baseUrl = requestBaseUrl(req);
|
|
142
|
+
if (baseUrl !== undefined && typeof entryUrl === 'function') {
|
|
143
|
+
try {
|
|
144
|
+
const href = entryUrl(`${baseUrl}/`);
|
|
145
|
+
if (typeof href === 'string' && href !== '') {
|
|
146
|
+
const parsed = new URL(href);
|
|
147
|
+
return `${parsed.pathname}${parsed.search}`;
|
|
148
|
+
}
|
|
149
|
+
} catch (error) {
|
|
150
|
+
log.warn(`无法生成 harness 入口地址,回退到 ${next} (${error?.message ?? String(error)})`);
|
|
151
|
+
}
|
|
152
|
+
} else if (warnedMissingEntry !== true) {
|
|
153
|
+
warnedMissingEntry = true;
|
|
154
|
+
log.error('拿不到 harness 的入口地址(connection 服务不可达):登录后浏览器会被直接送到 harness 的 401 页面。');
|
|
155
|
+
}
|
|
156
|
+
return next;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Whether an authenticated navigation must first pass through the harness's
|
|
161
|
+
* launch-token exchange: a browser holding a valid Feishu session but no
|
|
162
|
+
* `dsh-auth-*` cookie (fresh device, cleared cookies, harness restart) would
|
|
163
|
+
* otherwise land on the harness's 401 page with no way forward.
|
|
164
|
+
*
|
|
165
|
+
* The `token` guard keeps the exchange request itself from looping, and the
|
|
166
|
+
* handoff marker bounds the attempt when a browser refuses to store the
|
|
167
|
+
* harness cookie — without it, such a browser would bounce between `/` and
|
|
168
|
+
* `/?token=…` forever.
|
|
169
|
+
*/
|
|
170
|
+
function needsHarnessHandoff(req, url) {
|
|
171
|
+
if (url.pathname !== '/') return false;
|
|
172
|
+
if (url.searchParams.has('token')) return false;
|
|
173
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return false;
|
|
174
|
+
if (isNavigationRequest(req) !== true) return false;
|
|
175
|
+
const names = cookieNames(req?.headers?.cookie);
|
|
176
|
+
if (names.includes(HANDOFF_COOKIE)) return false;
|
|
177
|
+
return names.some((name) => name.startsWith('dsh-auth-')) !== true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Deliver the request to the harness exactly as it would have been delivered. */
|
|
181
|
+
async function passthrough(req, res, route, server) {
|
|
182
|
+
const target = typeof route?.handler === 'function' ? route.handler : server.fallback;
|
|
183
|
+
if (typeof target !== 'function') {
|
|
184
|
+
res.writeHead(404);
|
|
185
|
+
res.end();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
await target(req, res);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Refuse an unauthenticated request: redirect browsers, 401 everything else. */
|
|
192
|
+
function deny(req, res, url, principal) {
|
|
193
|
+
if (mode === 'misconfigured') {
|
|
194
|
+
sendHtml(res, 503, renderMisconfigured({ problem: fatalProblems.join(';') }));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (req?.headers?.['x-dsh-feishu-probe'] !== '1') {
|
|
198
|
+
log.warn(
|
|
199
|
+
`拒绝未认证请求 ${String(req?.method ?? 'GET')} ${url.pathname} host=${normalizeAuthority(req?.headers?.host) ?? 'no-host'} from ${clientAddress(req)} (${principal.reason})`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (isNavigationRequest(req)) {
|
|
203
|
+
redirect(res, 302, `${prefix}${LOGIN_PATH}?next=${encodeURIComponent(`${url.pathname}${url.search}`)}`);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
send(
|
|
207
|
+
res,
|
|
208
|
+
401,
|
|
209
|
+
`${JSON.stringify({ error: 'feishu_auth_required', message: '需要先通过飞书登录才能访问这台 DSH。', login: `${prefix}${LOGIN_PATH}` }, null, 2)}\n`,
|
|
210
|
+
'application/json',
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Start the OAuth flow: mint a state cookie and send the browser to Feishu. */
|
|
215
|
+
function handleLogin(req, res, url) {
|
|
216
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
217
|
+
sendText(res, 405, 'method not allowed');
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (mode === 'misconfigured') {
|
|
221
|
+
sendHtml(res, 503, renderMisconfigured({ problem: fatalProblems.join(';') }));
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const next = sanitizeNext(url.searchParams.get('next'));
|
|
225
|
+
if (authenticate(req).ok === true) {
|
|
226
|
+
redirect(res, 303, entryLocation(req, next));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const baseUrl = requestBaseUrl(req);
|
|
230
|
+
if (baseUrl === undefined) {
|
|
231
|
+
sendHtml(
|
|
232
|
+
res,
|
|
233
|
+
400,
|
|
234
|
+
renderError({
|
|
235
|
+
heading: '请求缺少可用的 Host',
|
|
236
|
+
message: '无法从该请求推导回调地址,请检查反向代理是否传递了 Host 头。',
|
|
237
|
+
loginPath: `${prefix}${LOGIN_PATH}`,
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const redirectUri = `${baseUrl}${prefix}${CALLBACK_PATH}`;
|
|
243
|
+
const nonce = generateToken(24);
|
|
244
|
+
const issuedAt = now();
|
|
245
|
+
const stateValue = signPayload(secret, {
|
|
246
|
+
kind: 'state',
|
|
247
|
+
nonce,
|
|
248
|
+
next,
|
|
249
|
+
redirectUri,
|
|
250
|
+
iat: issuedAt,
|
|
251
|
+
exp: issuedAt + STATE_TTL_MS,
|
|
252
|
+
});
|
|
253
|
+
redirect(res, 302, buildAuthorizeUrl({ clientId: config.appId, redirectUri, state: nonce }), {
|
|
254
|
+
'set-cookie': serializeCookie(STATE_COOKIE, stateValue, { maxAgeSeconds: STATE_TTL_MS / 1000, secure: isSecure(baseUrl) }),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Finish the OAuth flow: verify state, read the profile, mint the session. */
|
|
259
|
+
async function handleCallback(req, res, url) {
|
|
260
|
+
if (req.method !== 'GET') {
|
|
261
|
+
sendText(res, 405, 'method not allowed');
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (mode === 'misconfigured') {
|
|
265
|
+
sendHtml(res, 503, renderMisconfigured({ problem: fatalProblems.join(';') }));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const baseUrl = requestBaseUrl(req);
|
|
269
|
+
const secure = isSecure(baseUrl);
|
|
270
|
+
const clearState = expiredCookie(STATE_COOKIE, { secure });
|
|
271
|
+
const failure = (heading, message, detail) => {
|
|
272
|
+
sendHtml(res, 403, renderError({ heading, message, detail, loginPath: `${prefix}${LOGIN_PATH}` }), {
|
|
273
|
+
'set-cookie': clearState,
|
|
274
|
+
});
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const stateCookie = verifyPayload(secret, readCookie(req?.headers?.cookie, STATE_COOKIE));
|
|
278
|
+
if (stateCookie === undefined || stateCookie.kind !== 'state') {
|
|
279
|
+
failure('登录会话已失效', '没有找到有效的登录状态,请重新发起登录。');
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (typeof stateCookie.exp !== 'number' || stateCookie.exp <= now()) {
|
|
283
|
+
failure('登录超时', '这次登录停留得太久(超过 10 分钟),请重新发起。');
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const deniedByUser = url.searchParams.get('error');
|
|
287
|
+
if (deniedByUser !== null) {
|
|
288
|
+
failure('授权未完成', `飞书没有完成授权 (${deniedByUser})。`);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const code = url.searchParams.get('code');
|
|
292
|
+
const state = url.searchParams.get('state');
|
|
293
|
+
if (typeof code !== 'string' || code === '' || typeof state !== 'string' || state === '') {
|
|
294
|
+
failure('回调参数不完整', '飞书回调缺少 code 或 state 参数,请重新发起登录。');
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (safeEqual(state, stateCookie.nonce) !== true) {
|
|
298
|
+
failure('state 校验失败', '回调的 state 与本次登录不匹配,出于安全考虑已中止。');
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const address = clientAddress(req);
|
|
303
|
+
let user;
|
|
304
|
+
try {
|
|
305
|
+
const token = await exchangeCode({
|
|
306
|
+
clientId: config.appId,
|
|
307
|
+
clientSecret: config.appSecret,
|
|
308
|
+
code,
|
|
309
|
+
redirectUri:
|
|
310
|
+
typeof stateCookie.redirectUri === 'string' ? stateCookie.redirectUri : `${baseUrl ?? ''}${prefix}${CALLBACK_PATH}`,
|
|
311
|
+
fetchImpl,
|
|
312
|
+
});
|
|
313
|
+
user = await fetchUserInfo({ accessToken: token.accessToken, fetchImpl });
|
|
314
|
+
} catch (error) {
|
|
315
|
+
const detail = error?.message ?? String(error);
|
|
316
|
+
log.error(`登录失败 from ${address} :: ${detail}`);
|
|
317
|
+
failure('飞书登录失败', '无法用这次授权换取用户身份,请重试;若持续失败请查看 dsh web 终端日志。', detail);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const label = user.name ?? user.open_id;
|
|
322
|
+
const decision = decideAccess(user);
|
|
323
|
+
if (decision.allowed !== true) {
|
|
324
|
+
log.warn(`拒绝登录 name=${label} open_id=${user.open_id} :: ${decision.reason}`);
|
|
325
|
+
sendHtml(
|
|
326
|
+
res,
|
|
327
|
+
403,
|
|
328
|
+
renderDenied({ name: user.name, openId: user.open_id, reason: decision.reason, loginPath: `${prefix}${LOGIN_PATH}` }),
|
|
329
|
+
{ 'set-cookie': clearState },
|
|
330
|
+
);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const issuedAt = now();
|
|
335
|
+
const expiresAt = issuedAt + maxAgeMilliseconds;
|
|
336
|
+
const sessionValue = signPayload(secret, {
|
|
337
|
+
kind: 'session',
|
|
338
|
+
sub: user.open_id,
|
|
339
|
+
name: label,
|
|
340
|
+
tenant: user.tenant_key,
|
|
341
|
+
iat: issuedAt,
|
|
342
|
+
exp: expiresAt,
|
|
343
|
+
});
|
|
344
|
+
log.info(`登录成功 name=${label} open_id=${user.open_id} tenant=${user.tenant_key ?? '?'} from ${address}`);
|
|
345
|
+
redirect(res, 303, entryLocation(req, sanitizeNext(typeof stateCookie.next === 'string' ? stateCookie.next : '/')), {
|
|
346
|
+
'set-cookie': [
|
|
347
|
+
serializeCookie(SESSION_COOKIE, sessionValue, { maxAgeSeconds: maxAgeMilliseconds / 1000, secure }),
|
|
348
|
+
clearState,
|
|
349
|
+
],
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Clear this gate's cookies and the harness's own browser cookie. */
|
|
354
|
+
function handleLogout(req, res) {
|
|
355
|
+
if (req.method !== 'GET' && req.method !== 'POST') {
|
|
356
|
+
sendText(res, 405, 'method not allowed');
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const secure = isSecure(requestBaseUrl(req));
|
|
360
|
+
const cookies = [expiredCookie(SESSION_COOKIE, { secure }), expiredCookie(HANDOFF_COOKIE, { secure })];
|
|
361
|
+
// Expiring by name needs no knowledge of how the harness derives it.
|
|
362
|
+
for (const name of cookieNames(req?.headers?.cookie)) {
|
|
363
|
+
if (name.startsWith('dsh-auth-')) cookies.push(expiredCookie(name, { secure }));
|
|
364
|
+
}
|
|
365
|
+
sendHtml(res, 200, renderLoggedOut({ loginPath: `${prefix}${LOGIN_PATH}` }), { 'set-cookie': cookies });
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Report the caller's own gate state — the operator's diagnostic endpoint. */
|
|
369
|
+
function handleStatus(req, res) {
|
|
370
|
+
const principal = authenticate(req);
|
|
371
|
+
const payload = {
|
|
372
|
+
gate: mode,
|
|
373
|
+
authenticated: principal.ok === true,
|
|
374
|
+
reason: principal.ok === true ? undefined : principal.reason,
|
|
375
|
+
problems: fatalProblems.length > 0 ? fatalProblems : undefined,
|
|
376
|
+
};
|
|
377
|
+
if (principal.ok === true) {
|
|
378
|
+
payload.user = { name: principal.user.name, openId: principal.user.sub, tenantKey: principal.user.tenant };
|
|
379
|
+
payload.expiresAt = new Date(principal.user.exp).toISOString();
|
|
380
|
+
}
|
|
381
|
+
send(res, 200, `${JSON.stringify(payload, null, 2)}\n`, 'application/json');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function handleOwnPath(req, res, url) {
|
|
385
|
+
if (url.pathname === `${prefix}${LOGIN_PATH}`) return handleLogin(req, res, url);
|
|
386
|
+
if (url.pathname === `${prefix}${CALLBACK_PATH}`) return handleCallback(req, res, url);
|
|
387
|
+
if (url.pathname === `${prefix}${LOGOUT_PATH}`) return handleLogout(req, res);
|
|
388
|
+
if (url.pathname === `${prefix}${STATUS_PATH}`) return handleStatus(req, res);
|
|
389
|
+
if (url.pathname === prefix || url.pathname === `${prefix}/`) {
|
|
390
|
+
redirect(res, 302, `${prefix}${LOGIN_PATH}`);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
sendText(res, 404, 'not found');
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Decide one request, then either pass it through or refuse it. */
|
|
397
|
+
async function dispatch(req, res, route, server) {
|
|
398
|
+
let url;
|
|
399
|
+
try {
|
|
400
|
+
url = new URL(req?.url ?? '/', 'http://gate.invalid');
|
|
401
|
+
} catch {
|
|
402
|
+
sendText(res, 400, 'bad request');
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (isOwnPath(url.pathname)) {
|
|
406
|
+
try {
|
|
407
|
+
await handleOwnPath(req, res, url);
|
|
408
|
+
} catch (error) {
|
|
409
|
+
log.error(`处理 ${url.pathname} 时出错 :: ${error?.stack ?? String(error)}`);
|
|
410
|
+
if (res.headersSent !== true) sendText(res, 500, 'internal error');
|
|
411
|
+
else res.destroy();
|
|
412
|
+
}
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const principal = mode === 'misconfigured' ? { ok: false, reason: 'misconfigured' } : authenticate(req);
|
|
416
|
+
if (principal.ok !== true) {
|
|
417
|
+
deny(req, res, url, principal);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (needsHarnessHandoff(req, url)) {
|
|
421
|
+
const requested = `${url.pathname}${url.search}`;
|
|
422
|
+
const target = entryLocation(req, requested);
|
|
423
|
+
// A resolver that is missing or throws degrades to the requested path.
|
|
424
|
+
// Redirecting to it would repeat this exact state forever, so fall
|
|
425
|
+
// through to the harness instead: its own 401 page is terminal.
|
|
426
|
+
if (target !== requested) {
|
|
427
|
+
redirect(res, 303, target, {
|
|
428
|
+
'set-cookie': serializeCookie(HANDOFF_COOKIE, '1', {
|
|
429
|
+
maxAgeSeconds: HANDOFF_TTL_SECONDS,
|
|
430
|
+
secure: isSecure(requestBaseUrl(req)),
|
|
431
|
+
}),
|
|
432
|
+
});
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
await passthrough(req, res, route, server);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Install the interception on a live web server instance.
|
|
441
|
+
*
|
|
442
|
+
* Two hazards make the obvious implementation wrong, and both come from the
|
|
443
|
+
* harness exposing `webServer` as a Cordis service:
|
|
444
|
+
*
|
|
445
|
+
* - A member read returns a **fresh proxy** every time (Cordis wraps
|
|
446
|
+
* function-valued service members so a call is attributed to its caller),
|
|
447
|
+
* so `server.match === gateMatch` is never true and an identity-guarded
|
|
448
|
+
* disposer silently leaves the gate installed forever.
|
|
449
|
+
* - A reload can mount the replacement **before** the previous disposer
|
|
450
|
+
* runs, so an unconditional restore would tear down the live layer.
|
|
451
|
+
*
|
|
452
|
+
* The layer therefore identifies itself through a symbol-keyed marker on the
|
|
453
|
+
* installed function (readable through any wrapping proxy), records itself
|
|
454
|
+
* per server in {@link installedByServer}, and reuses the original captured
|
|
455
|
+
* by a leftover layer instead of stacking on top of it.
|
|
456
|
+
* @param server - the `webServer` service instance.
|
|
457
|
+
* @returns a disposer that restores the original dispatch.
|
|
458
|
+
* @throws when this harness version exposes no dispatch seam — refusing to
|
|
459
|
+
* run is the point: a silently unmounted gate would leave the page open.
|
|
460
|
+
*/
|
|
461
|
+
function install(server) {
|
|
462
|
+
const current = server?.match;
|
|
463
|
+
if (typeof current !== 'function') {
|
|
464
|
+
throw new Error(
|
|
465
|
+
'dsh-feishu-auth: 当前 dsh 版本的 webServer 没有可拦截的 match(pathname) 分发点,登录网关无法挂载;' +
|
|
466
|
+
'为避免页面在无保护状态下暴露,插件拒绝以不安全状态运行。请升级/降级 dsh 到兼容版本,或临时禁用该插件行。',
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
// The raw service is the stable key; every `ctx.webServer` read hands out a new proxy.
|
|
470
|
+
const key = server?.[CORDIS_ORIGINAL] ?? server;
|
|
471
|
+
// A layer this module left installed (older dsh versions could not unwrap)
|
|
472
|
+
// already knows the true original: inherit it rather than nesting.
|
|
473
|
+
const leftover = current[DISPATCHER];
|
|
474
|
+
const originalMatch = typeof leftover?.original === 'function' ? leftover.original : current;
|
|
475
|
+
const gateMatch = function gateMatch(pathname) {
|
|
476
|
+
const route = originalMatch.call(server, pathname);
|
|
477
|
+
return { kind: 'exact', path: pathname, handler: (req, res) => dispatch(req, res, route, server) };
|
|
478
|
+
};
|
|
479
|
+
gateMatch[DISPATCHER] = { gateMatch, original: originalMatch };
|
|
480
|
+
server.match = gateMatch;
|
|
481
|
+
installedByServer.set(key, gateMatch);
|
|
482
|
+
return () => {
|
|
483
|
+
// Only the newest layer may unwrap, and only while it is still on top:
|
|
484
|
+
// a reload mounts its replacement first and disposes the old fiber after.
|
|
485
|
+
if (installedByServer.get(key) !== gateMatch) return;
|
|
486
|
+
if (server.match?.[DISPATCHER]?.gateMatch !== gateMatch) return;
|
|
487
|
+
server.match = originalMatch;
|
|
488
|
+
installedByServer.delete(key);
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return { install, mode, prefix };
|
|
493
|
+
}
|