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/index.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-feishu-auth — Feishu (Lark) web-app login for the DSH web GUI.
|
|
3
|
+
*
|
|
4
|
+
* The plugin claims one interception point in front of the harness web
|
|
5
|
+
* server's dispatch, then serves its own OAuth endpoints and requires a signed
|
|
6
|
+
* session cookie for everything else. It answers on every address the server
|
|
7
|
+
* is bound to, because the decision never depends on the Host header.
|
|
8
|
+
*
|
|
9
|
+
* Two safety properties are deliberate:
|
|
10
|
+
* - Fail closed. Missing credentials do not stop the plugin from loading (a
|
|
11
|
+
* plugin that fails to load would leave the GUI wide open); they put the
|
|
12
|
+
* gate into a mode where every request is refused with the reason.
|
|
13
|
+
* - Fail loudly. After mounting, the gate probes its own listening socket in
|
|
14
|
+
* both directions. A gate that is not actually intercepting is reported as
|
|
15
|
+
* an error, not assumed.
|
|
16
|
+
*
|
|
17
|
+
* Zero runtime dependencies: node builtins only, so the package resolves in a
|
|
18
|
+
* profile that never installed it through pnpm.
|
|
19
|
+
* @module dsh-feishu-auth
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { randomBytes } from 'node:crypto';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { Config, SESSION_COOKIE, analyzeConfig } from './config.js';
|
|
26
|
+
import { createGate } from './gate.js';
|
|
27
|
+
import { loadOrCreateSecret, signPayload } from './session.js';
|
|
28
|
+
|
|
29
|
+
/** Stable Cordis plugin name. */
|
|
30
|
+
const name = 'feishu-auth';
|
|
31
|
+
|
|
32
|
+
/** Services required before the gate can mount. */
|
|
33
|
+
const inject = ['webServer'];
|
|
34
|
+
|
|
35
|
+
const PROBE_ATTEMPTS = 40;
|
|
36
|
+
const PROBE_DELAY_MS = 250;
|
|
37
|
+
const BLOCKED_STATUSES = new Set([302, 401, 403, 503]);
|
|
38
|
+
|
|
39
|
+
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
40
|
+
|
|
41
|
+
/** Resolve the harness home the same way the CLI does. */
|
|
42
|
+
function resolveHarnessHome() {
|
|
43
|
+
const fromEnvironment = process.env.DSH_HOME;
|
|
44
|
+
if (typeof fromEnvironment === 'string' && fromEnvironment !== '') return fromEnvironment;
|
|
45
|
+
return join(homedir(), '.dsh');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build the gate's logger.
|
|
50
|
+
*
|
|
51
|
+
* `ctx.logger` only feeds the harness's in-memory log buffer — nothing it
|
|
52
|
+
* receives is printed by the CLI — so every gate message is mirrored to stderr
|
|
53
|
+
* as well. An operator must be able to see "the gate is mounted", "the gate is
|
|
54
|
+
* refusing everyone", and "this account was denied" in the terminal that runs
|
|
55
|
+
* `dsh web`.
|
|
56
|
+
*/
|
|
57
|
+
function createOperatorLogger(ctx) {
|
|
58
|
+
const mirror = (level, message) => {
|
|
59
|
+
const text = String(message).replaceAll('\n', '\n ');
|
|
60
|
+
try {
|
|
61
|
+
process.stderr.write(`${new Date().toISOString()} feishu-auth[${level}] ${text}\n`);
|
|
62
|
+
} catch {
|
|
63
|
+
// A closed stderr must never break the gate.
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
return {
|
|
67
|
+
info: (message) => {
|
|
68
|
+
ctx?.logger?.info?.(message);
|
|
69
|
+
mirror('info', message);
|
|
70
|
+
},
|
|
71
|
+
warn: (message) => {
|
|
72
|
+
ctx?.logger?.warn?.(message);
|
|
73
|
+
mirror('warn', message);
|
|
74
|
+
},
|
|
75
|
+
error: (message) => {
|
|
76
|
+
ctx?.logger?.error?.(message);
|
|
77
|
+
mirror('error', message);
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Probe the gate through its own listening socket, in both directions: the
|
|
84
|
+
* unauthenticated request must be refused, and a request carrying a session
|
|
85
|
+
* this process just signed must reach the harness. Anything else means the
|
|
86
|
+
* interception is not in effect.
|
|
87
|
+
*/
|
|
88
|
+
async function verifyGate({ server, secret, logger }) {
|
|
89
|
+
const port = server?.port;
|
|
90
|
+
if (!Number.isInteger(port) || port <= 0) return;
|
|
91
|
+
const host = server.host === '0.0.0.0' ? '127.0.0.1' : (server.host ?? '127.0.0.1');
|
|
92
|
+
const probeUrl = `http://${host}:${String(port)}/_dsh_feishu_auth_probe_`;
|
|
93
|
+
const probeHeaders = { accept: '*/*', 'x-dsh-feishu-probe': '1' };
|
|
94
|
+
for (let attempt = 0; attempt < PROBE_ATTEMPTS; attempt += 1) {
|
|
95
|
+
try {
|
|
96
|
+
const anonymous = await fetch(probeUrl, { headers: probeHeaders, redirect: 'manual' });
|
|
97
|
+
if (BLOCKED_STATUSES.has(anonymous.status) !== true) {
|
|
98
|
+
logger.error(
|
|
99
|
+
`自检失败:未登录请求返回 HTTP ${String(anonymous.status)},网关没有生效。请检查插件行是否启用,并确认没有其它插件覆盖 webServer.match。`,
|
|
100
|
+
);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const sessionValue = signPayload(secret, {
|
|
104
|
+
kind: 'session',
|
|
105
|
+
sub: 'self-check',
|
|
106
|
+
name: 'self-check',
|
|
107
|
+
iat: Date.now(),
|
|
108
|
+
exp: Date.now() + 60_000,
|
|
109
|
+
});
|
|
110
|
+
const authenticated = await fetch(probeUrl, {
|
|
111
|
+
headers: { ...probeHeaders, cookie: `${SESSION_COOKIE}=${sessionValue}` },
|
|
112
|
+
redirect: 'manual',
|
|
113
|
+
});
|
|
114
|
+
if (BLOCKED_STATUSES.has(authenticated.status)) {
|
|
115
|
+
logger.error(`自检失败:持有效会话的请求仍被拒绝 (HTTP ${String(authenticated.status)}),登录后将无法访问页面。`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
logger.info(
|
|
119
|
+
`网关自检通过(未登录 → HTTP ${String(anonymous.status)},已登录 → HTTP ${String(authenticated.status)})`,
|
|
120
|
+
);
|
|
121
|
+
return;
|
|
122
|
+
} catch {
|
|
123
|
+
// The socket is not accepting yet; the server binds during its own init.
|
|
124
|
+
}
|
|
125
|
+
await delay(PROBE_DELAY_MS);
|
|
126
|
+
}
|
|
127
|
+
logger.warn('自检未完成:服务器在预期时间内没有开始监听,无法确认网关状态。');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Mount the Feishu login gate.
|
|
132
|
+
* @param ctx - plugin context carrying the webServer service.
|
|
133
|
+
* @param rawConfig - the config object from this plugin's patch row.
|
|
134
|
+
*/
|
|
135
|
+
async function apply(ctx, rawConfig = {}) {
|
|
136
|
+
const { config, fatal } = analyzeConfig(rawConfig);
|
|
137
|
+
const logger = createOperatorLogger(ctx);
|
|
138
|
+
|
|
139
|
+
const fatalProblems = [...fatal];
|
|
140
|
+
const secretFile = join(resolveHarnessHome(), 'feishu-auth', 'session-secret');
|
|
141
|
+
let secret;
|
|
142
|
+
try {
|
|
143
|
+
secret = loadOrCreateSecret(secretFile);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
fatalProblems.push(`无法读写会话密钥 ${secretFile}:${error?.message ?? String(error)}`);
|
|
146
|
+
secret = randomBytes(32);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Set while the `connection` injection is live; read per request by the gate. */
|
|
150
|
+
let entryUrlProvider;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The harness entry URL carrying this process's launch token.
|
|
154
|
+
*
|
|
155
|
+
* `connection` is reachable only through `ctx.inject`: the scoped callback's
|
|
156
|
+
* context carries the injected service, while `ctx.get('connection')` and
|
|
157
|
+
* `ctx.connection` both fail here. Losing this resolver is not fatal to
|
|
158
|
+
* mounting, so it must fail loud rather than silently degrade every login.
|
|
159
|
+
*/
|
|
160
|
+
ctx.inject(['connection'], (connectionCtx) => {
|
|
161
|
+
const { connection } = connectionCtx;
|
|
162
|
+
if (typeof connection?.authenticatedUrl !== 'function') {
|
|
163
|
+
logger.error('connection 服务没有 authenticatedUrl(),无法把浏览器交给 harness 的令牌兑换流程。');
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
connectionCtx.effect(() => {
|
|
167
|
+
entryUrlProvider = (baseUrl) => connection.authenticatedUrl(baseUrl);
|
|
168
|
+
return () => {
|
|
169
|
+
entryUrlProvider = undefined;
|
|
170
|
+
};
|
|
171
|
+
}, 'feishu-auth: harness entry resolver');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const gate = createGate({
|
|
175
|
+
config,
|
|
176
|
+
secret,
|
|
177
|
+
fatalProblems,
|
|
178
|
+
logger,
|
|
179
|
+
entryUrl: (baseUrl) => entryUrlProvider?.(baseUrl),
|
|
180
|
+
});
|
|
181
|
+
ctx.effect(() => gate.install(ctx.webServer), 'feishu-auth: http gate');
|
|
182
|
+
|
|
183
|
+
if (fatalProblems.length > 0) {
|
|
184
|
+
logger.error(`网关已进入故障关闭模式,所有页面访问都会被拒绝:\n - ${fatalProblems.join('\n - ')}`);
|
|
185
|
+
} else {
|
|
186
|
+
logger.info(
|
|
187
|
+
`飞书登录已挂载 prefix=${gate.prefix} 允许范围=${
|
|
188
|
+
config.allowedUsers.length > 0 ? `${String(config.allowedUsers.length)} 个账号` : '本应用可用范围内的任意成员'
|
|
189
|
+
} 会话有效期=${String(config.sessionMaxAgeDays)} 天`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
void verifyGate({ server: ctx.webServer, secret, logger });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export { Config, apply, inject, name };
|
package/lib/pages.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-contained HTML for the gate's terminal states: denied, error, logged
|
|
3
|
+
* out, and not-configured. Everything is inline, so these pages never depend
|
|
4
|
+
* on the assets the gate protects.
|
|
5
|
+
* @module dsh-feishu-auth/pages
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const STYLE = `
|
|
9
|
+
:root { color-scheme: light dark; }
|
|
10
|
+
* { box-sizing: border-box; }
|
|
11
|
+
body {
|
|
12
|
+
margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
|
13
|
+
padding: 32px 20px; background: #f5f5f7; color: #1d1d1f;
|
|
14
|
+
font: 15px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", Roboto, sans-serif;
|
|
15
|
+
}
|
|
16
|
+
@media (prefers-color-scheme: dark) { body { background: #16171a; color: #e8e8ea; } }
|
|
17
|
+
main {
|
|
18
|
+
width: 100%; max-width: 520px; background: #fff; border-radius: 14px; padding: 32px;
|
|
19
|
+
box-shadow: 0 1px 2px rgba(0,0,0,.06), 0 12px 32px rgba(0,0,0,.08);
|
|
20
|
+
}
|
|
21
|
+
@media (prefers-color-scheme: dark) { main { background: #202124; box-shadow: 0 1px 2px rgba(0,0,0,.4); } }
|
|
22
|
+
.badge { font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: #8a8a8e; margin-bottom: 10px; }
|
|
23
|
+
h1 { font-size: 20px; margin: 0 0 12px; }
|
|
24
|
+
p { margin: 0 0 12px; }
|
|
25
|
+
code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; word-break: break-all; }
|
|
26
|
+
dl { margin: 0 0 16px; padding: 14px 16px; border-radius: 10px; background: rgba(127,127,127,.10); }
|
|
27
|
+
dt { font-size: 12px; color: #8a8a8e; margin-top: 8px; }
|
|
28
|
+
dt:first-child { margin-top: 0; }
|
|
29
|
+
dd { margin: 2px 0 0; }
|
|
30
|
+
.actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 20px; }
|
|
31
|
+
a.button { display: inline-block; padding: 9px 16px; border-radius: 8px; text-decoration: none; font-weight: 600; background: #1a73e8; color: #fff; }
|
|
32
|
+
.tone-error a.button { background: #d93025; }
|
|
33
|
+
.note { font-size: 13px; color: #8a8a8e; }
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Escape text placed into HTML text or a quoted attribute.
|
|
38
|
+
* @param value - untrusted text.
|
|
39
|
+
* @returns escaped text.
|
|
40
|
+
*/
|
|
41
|
+
export function escapeHtml(value) {
|
|
42
|
+
return String(value ?? '')
|
|
43
|
+
.replaceAll('&', '&')
|
|
44
|
+
.replaceAll('<', '<')
|
|
45
|
+
.replaceAll('>', '>')
|
|
46
|
+
.replaceAll('"', '"')
|
|
47
|
+
.replaceAll("'", ''');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function renderPage({ title, heading, body, tone = 'neutral' }) {
|
|
51
|
+
return `<!doctype html>
|
|
52
|
+
<html lang="zh-CN">
|
|
53
|
+
<head>
|
|
54
|
+
<meta charset="utf-8">
|
|
55
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
56
|
+
<meta name="robots" content="noindex, nofollow">
|
|
57
|
+
<title>${escapeHtml(title)}</title>
|
|
58
|
+
<style>${STYLE}</style>
|
|
59
|
+
</head>
|
|
60
|
+
<body>
|
|
61
|
+
<main class="tone-${escapeHtml(tone)}">
|
|
62
|
+
<div class="badge">DSH · 飞书登录</div>
|
|
63
|
+
<h1>${escapeHtml(heading)}</h1>
|
|
64
|
+
${body}
|
|
65
|
+
</main>
|
|
66
|
+
</body>
|
|
67
|
+
</html>
|
|
68
|
+
`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function retry(loginPath, label = '重新登录') {
|
|
72
|
+
return `<div class="actions"><a class="button" href="${escapeHtml(loginPath)}">${escapeHtml(label)}</a></div>`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function facts(rows) {
|
|
76
|
+
const items = rows
|
|
77
|
+
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
|
78
|
+
.map(([label, value]) => `<dt>${escapeHtml(label)}</dt><dd class="mono">${escapeHtml(value)}</dd>`)
|
|
79
|
+
.join('');
|
|
80
|
+
return items === '' ? '' : `<dl>${items}</dl>`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The page an authenticated-but-unauthorized account sees: its own identifiers
|
|
85
|
+
* and nothing about anyone else, so the operator can allowlist the right value.
|
|
86
|
+
* @param options - the signed-in account, the reason, and the login path.
|
|
87
|
+
* @returns the HTML document.
|
|
88
|
+
*/
|
|
89
|
+
export function renderDenied({ name, openId, reason, loginPath }) {
|
|
90
|
+
const body = `
|
|
91
|
+
<p>飞书已完成身份认证,但这个账号没有访问这台 DSH 的权限。</p>
|
|
92
|
+
<p><b>原因:</b>${escapeHtml(reason)}</p>
|
|
93
|
+
${facts([['姓名', name], ['open_id', openId]])}
|
|
94
|
+
<p class="note">把上面的 open_id 加入配置的 <code>allowedUsers</code>(或把该成员加入飞书应用的可用范围),然后重启 <code>dsh web</code>。</p>
|
|
95
|
+
${retry(loginPath, '换一个飞书账号登录')}`;
|
|
96
|
+
return renderPage({ title: '无权访问 · DSH', heading: '这个账号没有访问权限', body, tone: 'error' });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A terminal error page for a failed or expired login attempt.
|
|
101
|
+
* @param options - heading, message, optional detail, and the login path.
|
|
102
|
+
* @returns the HTML document.
|
|
103
|
+
*/
|
|
104
|
+
export function renderError({ heading, message, detail, loginPath }) {
|
|
105
|
+
const body = `
|
|
106
|
+
<p>${escapeHtml(message)}</p>
|
|
107
|
+
${detail === undefined || detail === '' ? '' : `<p class="note mono">${escapeHtml(detail)}</p>`}
|
|
108
|
+
${retry(loginPath)}`;
|
|
109
|
+
return renderPage({ title: '登录失败 · DSH', heading, body, tone: 'error' });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The page shown after an explicit logout.
|
|
114
|
+
* @param options - the login path.
|
|
115
|
+
* @returns the HTML document.
|
|
116
|
+
*/
|
|
117
|
+
export function renderLoggedOut({ loginPath }) {
|
|
118
|
+
return renderPage({
|
|
119
|
+
title: '已退出 · DSH',
|
|
120
|
+
heading: '已退出登录',
|
|
121
|
+
body: `<p>本机浏览器上的访问凭据已清除。</p>${retry(loginPath, '使用飞书登录')}`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The fail-closed page: the gate is mounted but cannot authenticate anyone, so
|
|
127
|
+
* the page stays shut until configuration is fixed.
|
|
128
|
+
* @param options - the configuration problem.
|
|
129
|
+
* @returns the HTML document.
|
|
130
|
+
*/
|
|
131
|
+
export function renderMisconfigured({ problem }) {
|
|
132
|
+
const body = `
|
|
133
|
+
<p>飞书登录未配置完成,因此<b>所有访问都被拒绝</b>(包括你自己的)。</p>
|
|
134
|
+
<p><b>问题:</b>${escapeHtml(problem)}</p>
|
|
135
|
+
<p class="note">在终端修好配置后重启 <code>dsh web</code> 即可恢复。急着重启:见 README「停用」。</p>`;
|
|
136
|
+
return renderPage({ title: '未就绪 · DSH', heading: '飞书登录未就绪', body, tone: 'error' });
|
|
137
|
+
}
|
package/lib/session.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signed-cookie sessions for the Feishu gate.
|
|
3
|
+
*
|
|
4
|
+
* One HMAC-SHA256 secret signs two kinds of payload: the short-lived OAuth
|
|
5
|
+
* `state` cookie that survives the round trip through Feishu, and the browser
|
|
6
|
+
* session cookie minted after a successful login. Both are self-contained
|
|
7
|
+
* (no server-side session store), tamper-evident, and bounded by `exp`.
|
|
8
|
+
* @module dsh-feishu-auth/session
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
12
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { dirname } from 'node:path';
|
|
14
|
+
|
|
15
|
+
/** Cookie payload version prefix; a future format bumps it instead of guessing. */
|
|
16
|
+
export const COOKIE_VERSION = 'v1';
|
|
17
|
+
|
|
18
|
+
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Encode bytes as unpadded base64url, the only alphabet safe in a cookie value.
|
|
22
|
+
* @param value - bytes or a string to encode.
|
|
23
|
+
* @returns the unpadded base64url text.
|
|
24
|
+
*/
|
|
25
|
+
export function encodeBase64Url(value) {
|
|
26
|
+
return Buffer.from(value).toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, '');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Decode unpadded base64url text, rejecting anything that is not canonical
|
|
31
|
+
* (so two different strings can never decode to the same bytes).
|
|
32
|
+
* @param value - the base64url text.
|
|
33
|
+
* @returns the decoded bytes, or undefined when the input is not canonical.
|
|
34
|
+
*/
|
|
35
|
+
export function decodeBase64Url(value) {
|
|
36
|
+
if (typeof value !== 'string' || value.length === 0) return undefined;
|
|
37
|
+
if (!BASE64URL_PATTERN.test(value) || value.length % 4 === 1) return undefined;
|
|
38
|
+
const padding = '='.repeat((4 - (value.length % 4)) % 4);
|
|
39
|
+
const decoded = Buffer.from(value.replaceAll('-', '+').replaceAll('_', '/') + padding, 'base64');
|
|
40
|
+
return encodeBase64Url(decoded) === value ? decoded : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Generate a fresh random token in cookie-safe form.
|
|
45
|
+
* @param bytes - entropy size; 32 bytes for secrets, 24 for one-shot nonces.
|
|
46
|
+
* @returns the encoded token.
|
|
47
|
+
*/
|
|
48
|
+
export function generateToken(bytes = 32) {
|
|
49
|
+
return encodeBase64Url(randomBytes(bytes));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function signature(secret, body) {
|
|
53
|
+
return createHmac('sha256', secret).update(body).digest();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Serialize a payload into `v1.<body>.<signature>`.
|
|
58
|
+
* @param secret - the signing secret bytes.
|
|
59
|
+
* @param payload - any JSON-serializable payload.
|
|
60
|
+
* @returns the signed cookie value.
|
|
61
|
+
*/
|
|
62
|
+
export function signPayload(secret, payload) {
|
|
63
|
+
const body = encodeBase64Url(Buffer.from(JSON.stringify(payload), 'utf8'));
|
|
64
|
+
return `${COOKIE_VERSION}.${body}.${encodeBase64Url(signature(secret, body))}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Verify and decode a signed value. Any structural damage, signature mismatch,
|
|
69
|
+
* or non-object JSON is a plain rejection: the caller decides what to do.
|
|
70
|
+
* @param secret - the signing secret bytes.
|
|
71
|
+
* @param value - the cookie value to verify.
|
|
72
|
+
* @returns the decoded payload, or undefined when it is not authentic.
|
|
73
|
+
*/
|
|
74
|
+
export function verifyPayload(secret, value) {
|
|
75
|
+
if (typeof value !== 'string') return undefined;
|
|
76
|
+
const parts = value.split('.');
|
|
77
|
+
if (parts.length !== 3) return undefined;
|
|
78
|
+
const [version, body, provided] = parts;
|
|
79
|
+
if (version !== COOKIE_VERSION || body === undefined || provided === undefined) return undefined;
|
|
80
|
+
const providedBytes = decodeBase64Url(provided);
|
|
81
|
+
if (providedBytes === undefined) return undefined;
|
|
82
|
+
const expectedBytes = signature(secret, body);
|
|
83
|
+
if (providedBytes.byteLength !== expectedBytes.byteLength) return undefined;
|
|
84
|
+
if (!timingSafeEqual(providedBytes, expectedBytes)) return undefined;
|
|
85
|
+
const decoded = decodeBase64Url(body);
|
|
86
|
+
if (decoded === undefined) return undefined;
|
|
87
|
+
try {
|
|
88
|
+
const payload = JSON.parse(decoded.toString('utf8'));
|
|
89
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return undefined;
|
|
90
|
+
return payload;
|
|
91
|
+
} catch {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Constant-time string comparison for OAuth state nonces.
|
|
98
|
+
* @param actual - value received in the callback query.
|
|
99
|
+
* @param expected - value recorded in the state cookie.
|
|
100
|
+
* @returns true only when both are identical.
|
|
101
|
+
*/
|
|
102
|
+
export function safeEqual(actual, expected) {
|
|
103
|
+
if (typeof actual !== 'string' || typeof expected !== 'string') return false;
|
|
104
|
+
const actualBytes = Buffer.from(actual, 'utf8');
|
|
105
|
+
const expectedBytes = Buffer.from(expected, 'utf8');
|
|
106
|
+
if (actualBytes.byteLength !== expectedBytes.byteLength) return false;
|
|
107
|
+
return timingSafeEqual(actualBytes, expectedBytes);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Parse a Cookie header into a name to value map. Generated cookie names and
|
|
112
|
+
* values are cookie-safe base64url, so a simple split is enough and no general
|
|
113
|
+
* cookie grammar is reimplemented here.
|
|
114
|
+
* @param headerValue - the raw `Cookie` header.
|
|
115
|
+
* @returns a map of the cookies that were present.
|
|
116
|
+
*/
|
|
117
|
+
export function parseCookies(headerValue) {
|
|
118
|
+
const cookies = new Map();
|
|
119
|
+
if (typeof headerValue !== 'string') return cookies;
|
|
120
|
+
for (const segment of headerValue.split(';')) {
|
|
121
|
+
const at = segment.indexOf('=');
|
|
122
|
+
if (at === -1) continue;
|
|
123
|
+
const name = segment.slice(0, at).trim();
|
|
124
|
+
if (name === '') continue;
|
|
125
|
+
cookies.set(name, segment.slice(at + 1).trim());
|
|
126
|
+
}
|
|
127
|
+
return cookies;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* List the cookie names present on a request. Used to expire the harness's own
|
|
132
|
+
* signed browser cookie on logout without knowing how it derives that name.
|
|
133
|
+
* @param headerValue - the raw `Cookie` header.
|
|
134
|
+
* @returns the cookie names, in header order.
|
|
135
|
+
*/
|
|
136
|
+
export function cookieNames(headerValue) {
|
|
137
|
+
return [...parseCookies(headerValue).keys()];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Read one cookie out of the parsed map.
|
|
142
|
+
* @param headerValue - the raw `Cookie` header.
|
|
143
|
+
* @param name - the cookie name.
|
|
144
|
+
* @returns the value, or undefined when absent.
|
|
145
|
+
*/
|
|
146
|
+
export function readCookie(headerValue, name) {
|
|
147
|
+
return parseCookies(headerValue).get(name);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Serialize a `Set-Cookie` value for a session/state cookie.
|
|
152
|
+
*
|
|
153
|
+
* `SameSite=Lax` is required, not cosmetic: the OAuth callback arrives as a
|
|
154
|
+
* cross-site top-level GET navigation, which Lax still sends while Strict
|
|
155
|
+
* would not. `Secure` follows the scheme the cookie was minted on so a
|
|
156
|
+
* plain-HTTP LAN address keeps working.
|
|
157
|
+
* @param name - cookie name.
|
|
158
|
+
* @param value - cookie value.
|
|
159
|
+
* @param options - lifetime, Secure flag, and SameSite policy.
|
|
160
|
+
* @returns the header value.
|
|
161
|
+
*/
|
|
162
|
+
export function serializeCookie(name, value, { maxAgeSeconds, secure = false, sameSite = 'Lax', httpOnly = true } = {}) {
|
|
163
|
+
const parts = [`${name}=${value}`, 'Path=/'];
|
|
164
|
+
if (Number.isFinite(maxAgeSeconds)) parts.push(`Max-Age=${String(Math.max(0, Math.floor(maxAgeSeconds)))}`);
|
|
165
|
+
if (httpOnly) parts.push('HttpOnly');
|
|
166
|
+
if (secure) parts.push('Secure');
|
|
167
|
+
parts.push(`SameSite=${sameSite}`);
|
|
168
|
+
return parts.join('; ');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Serialize the deletion form of a cookie.
|
|
173
|
+
* @param name - cookie name.
|
|
174
|
+
* @param options - `secure` must match how the cookie was set.
|
|
175
|
+
* @returns the header value that expires the cookie.
|
|
176
|
+
*/
|
|
177
|
+
export function expiredCookie(name, { secure = false } = {}) {
|
|
178
|
+
return serializeCookie(name, '', { maxAgeSeconds: 0, secure });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Load the gate's signing secret from `file`, creating it once with owner-only
|
|
183
|
+
* permissions. A stable secret is what lets a login survive a harness restart.
|
|
184
|
+
* @param file - absolute path of the secret file.
|
|
185
|
+
* @returns the secret bytes.
|
|
186
|
+
*/
|
|
187
|
+
export function loadOrCreateSecret(file) {
|
|
188
|
+
try {
|
|
189
|
+
const existing = readFileSync(file, 'utf8').trim();
|
|
190
|
+
const decoded = decodeBase64Url(existing);
|
|
191
|
+
if (decoded !== undefined && decoded.byteLength >= 16) return decoded;
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
194
|
+
}
|
|
195
|
+
const secret = randomBytes(32);
|
|
196
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
197
|
+
const temporary = `${file}.${process.pid.toString()}.tmp`;
|
|
198
|
+
writeFileSync(temporary, `${encodeBase64Url(secret)}\n`, { mode: 0o600 });
|
|
199
|
+
renameSync(temporary, file);
|
|
200
|
+
try {
|
|
201
|
+
chmodSync(file, 0o600);
|
|
202
|
+
} catch {
|
|
203
|
+
// A filesystem without POSIX modes still has the secret safely written.
|
|
204
|
+
}
|
|
205
|
+
return secret;
|
|
206
|
+
}
|
package/lib/urls.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request-derived URLs and the open-redirect guard.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions of request-shaped data, so the security-relevant decisions
|
|
5
|
+
* stay testable without a live server.
|
|
6
|
+
* @module dsh-feishu-auth/urls
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Canonicalize a Host header into a lowercase `host[:port]` authority.
|
|
11
|
+
*
|
|
12
|
+
* Rejects anything carrying userinfo, a path, a query, a fragment, whitespace,
|
|
13
|
+
* or a backslash, so a hostile Host can never become a `Set-Cookie` audience
|
|
14
|
+
* for another origin.
|
|
15
|
+
* @param host - the raw `Host` header value.
|
|
16
|
+
* @returns the canonical authority, or undefined when it is not one.
|
|
17
|
+
*/
|
|
18
|
+
export function normalizeAuthority(host) {
|
|
19
|
+
if (typeof host !== 'string') return undefined;
|
|
20
|
+
const trimmed = host.trim();
|
|
21
|
+
if (trimmed.length === 0 || trimmed.length > 255) return undefined;
|
|
22
|
+
if (/[\s/\\?#@]/.test(trimmed)) return undefined;
|
|
23
|
+
try {
|
|
24
|
+
const url = new URL(`http://${trimmed}`);
|
|
25
|
+
if (url.pathname !== '/' || url.search !== '' || url.hash !== '') return undefined;
|
|
26
|
+
return url.host.toLowerCase();
|
|
27
|
+
} catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the externally visible origin of this request: scheme plus Host as
|
|
34
|
+
* the request presents them.
|
|
35
|
+
*
|
|
36
|
+
* The OAuth `redirect_uri` is derived from this value, so one deployment
|
|
37
|
+
* serves a LAN IP, loopback, and a tunnel hostname without per-address
|
|
38
|
+
* configuration. A reverse proxy's `X-Forwarded-Proto` is honored because a
|
|
39
|
+
* forged value can only break the caller's own login: it would hand out a
|
|
40
|
+
* `Secure` cookie over plain http, which the browser then refuses.
|
|
41
|
+
* @param req - incoming request.
|
|
42
|
+
* @returns origin without a trailing slash, or undefined for an invalid Host.
|
|
43
|
+
*/
|
|
44
|
+
export function requestBaseUrl(req) {
|
|
45
|
+
const authority = normalizeAuthority(req?.headers?.host);
|
|
46
|
+
if (authority === undefined) return undefined;
|
|
47
|
+
const forwarded = req?.headers?.['x-forwarded-proto'];
|
|
48
|
+
const first = typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim().toLowerCase() : undefined;
|
|
49
|
+
const scheme = first === 'https' || first === 'http' ? first : req?.socket?.encrypted === true ? 'https' : 'http';
|
|
50
|
+
return `${scheme}://${authority}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Sanitize a `next` target into a same-origin absolute path.
|
|
55
|
+
* @param raw - the untrusted `next` query value.
|
|
56
|
+
* @returns an absolute path beginning with exactly one `/`.
|
|
57
|
+
*/
|
|
58
|
+
export function sanitizeNext(raw) {
|
|
59
|
+
if (typeof raw !== 'string' || raw.length === 0 || raw.length > 2048) return '/';
|
|
60
|
+
if (!raw.startsWith('/') || raw.startsWith('//')) return '/';
|
|
61
|
+
// eslint-disable-next-line no-control-regex -- rejecting control characters is the point
|
|
62
|
+
if (/[\u0000-\u001f\u007f]/.test(raw)) return '/';
|
|
63
|
+
// Some clients normalize '\' to '/', which would resurrect a '//' prefix.
|
|
64
|
+
if (raw.includes('\\')) return '/';
|
|
65
|
+
return raw;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Client address for the audit log: the socket peer, plus the first forwarded
|
|
70
|
+
* hop when a proxy supplied one.
|
|
71
|
+
* @param req - incoming request.
|
|
72
|
+
* @returns a loggable address string.
|
|
73
|
+
*/
|
|
74
|
+
export function clientAddress(req) {
|
|
75
|
+
const socketAddress = req?.socket?.remoteAddress ?? 'unknown';
|
|
76
|
+
const forwarded = req?.headers?.['x-forwarded-for'];
|
|
77
|
+
const first = typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : undefined;
|
|
78
|
+
return first === undefined || first === '' ? socketAddress : `${first} via ${socketAddress}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Whether this request is a browser navigation, which decides between a
|
|
83
|
+
* redirect to the login page and a JSON 401 for programmatic callers.
|
|
84
|
+
* @param req - incoming request.
|
|
85
|
+
* @returns true when the response should be an HTML redirect.
|
|
86
|
+
*/
|
|
87
|
+
export function isNavigationRequest(req) {
|
|
88
|
+
if (req?.method !== 'GET') return false;
|
|
89
|
+
const accept = req?.headers?.accept;
|
|
90
|
+
if (typeof accept === 'string' && accept.includes('text/html')) return true;
|
|
91
|
+
// Fetch Metadata is authoritative when the browser sends it and there is no
|
|
92
|
+
// Accept header at all.
|
|
93
|
+
return req?.headers?.['sec-fetch-mode'] === 'navigate' && accept === undefined;
|
|
94
|
+
}
|