@huaqiu/dsh-auth 0.1.1 → 0.2.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/lib/client.js +48 -11
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +77 -2
- package/lib/index.mjs +206 -6
- package/package.json +1 -1
- package/src/client/client.ts +8 -0
- package/src/client/index.tsx +23 -5
- package/src/client/transport.ts +23 -0
- package/src/host.ts +17 -1
- package/src/routes.ts +23 -6
- package/src/service.ts +102 -5
- package/src/validation.ts +182 -0
package/lib/index.d.mts
CHANGED
|
@@ -28,28 +28,103 @@ interface HuaqiuAuthConfig {
|
|
|
28
28
|
hostAuthPath?: string;
|
|
29
29
|
/** Seconds a host session is reused before re-fetching. Default 300. */
|
|
30
30
|
hostSessionTtlSeconds?: number;
|
|
31
|
+
/** Seconds remote token-validation results are cached. Default 60. */
|
|
32
|
+
validationTtlSeconds?: number;
|
|
31
33
|
}
|
|
32
34
|
//#endregion
|
|
35
|
+
//#region src/validation.d.ts
|
|
36
|
+
/**
|
|
37
|
+
* Token validation for `@huaqiu/dsh-auth`.
|
|
38
|
+
*
|
|
39
|
+
* Single authoritative validation path shared by standalone (auth.eda.cn) and
|
|
40
|
+
* HQ Edge host credentials. Uses the existing Huaqiu endpoint
|
|
41
|
+
*
|
|
42
|
+
* GET https://www.eda.cn/api/token/validate?token=<token>
|
|
43
|
+
* → { code, message, result: boolean }
|
|
44
|
+
*
|
|
45
|
+
* (the same endpoint consumed by `NextChat/app/auth/is_token_valid.ts`; probe:
|
|
46
|
+
* `curl "https://www.eda.cn/api/token/validate?token=__dummy__"` → 200
|
|
47
|
+
* `{"code":200000,"message":"success","result":false}`).
|
|
48
|
+
*
|
|
49
|
+
* Local expiry is a cheap pre-check only (never authoritative). Remote
|
|
50
|
+
* validation is authoritative, short-lived in-memory cached, and never
|
|
51
|
+
* persisted. Network / 5xx failures are reported as `unavailable` — they are
|
|
52
|
+
* NOT converted into "token invalid", so a transient network blip never forces
|
|
53
|
+
* the user to log in again.
|
|
54
|
+
*/
|
|
55
|
+
/**
|
|
56
|
+
* Outcome of an authoritative token validation.
|
|
57
|
+
*
|
|
58
|
+
* - `valid` — the Huaqiu API accepted the token.
|
|
59
|
+
* - `invalid` — the token is definitively rejected/expired.
|
|
60
|
+
* - `unavailable` — validation could not be performed (network/5xx); the token
|
|
61
|
+
* is NOT declared invalid (spec §17/§18).
|
|
62
|
+
*/
|
|
63
|
+
type AuthValidationResult = {
|
|
64
|
+
status: 'valid';
|
|
65
|
+
userId?: string;
|
|
66
|
+
expiresAt?: number;
|
|
67
|
+
} | {
|
|
68
|
+
status: 'invalid';
|
|
69
|
+
reason: 'expired' | 'unauthorized' | 'forbidden' | 'invalid';
|
|
70
|
+
} | {
|
|
71
|
+
status: 'unavailable';
|
|
72
|
+
error: Error;
|
|
73
|
+
};
|
|
74
|
+
//#endregion
|
|
33
75
|
//#region src/service.d.ts
|
|
34
76
|
interface HuaqiuUserInfo {
|
|
35
77
|
id: string;
|
|
36
78
|
token: string;
|
|
37
79
|
nickname?: string;
|
|
80
|
+
/** Unix seconds; known for browser-pushed sessions (auth.eda.cn window). */
|
|
81
|
+
expiresAt?: number;
|
|
38
82
|
}
|
|
39
83
|
interface HuaqiuAuthApi {
|
|
40
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Authoritative async check: a credential exists AND is known to be valid
|
|
86
|
+
* (local expiry + cached remote validation). Never a mere token-presence
|
|
87
|
+
* check — a host token supplied by hq-edge is not assumed valid just because
|
|
88
|
+
* it exists (spec §10). Short-circuits to `false` after `invalidate()` until
|
|
89
|
+
* re-validated or a fresh credential arrives.
|
|
90
|
+
*/
|
|
91
|
+
isAuthenticated(): Promise<boolean>;
|
|
41
92
|
getAccessToken(): Promise<string | null>;
|
|
42
93
|
getUserInfo(): Promise<HuaqiuUserInfo | null>;
|
|
43
94
|
/** Node-side no-op: login always happens in the browser. */
|
|
44
95
|
login(): Promise<void>;
|
|
45
96
|
logout(): Promise<void>;
|
|
97
|
+
/**
|
|
98
|
+
* Single authoritative validation path (spec §7). Works identically for
|
|
99
|
+
* standalone and host credentials; never depends on hq-edge.
|
|
100
|
+
*/
|
|
101
|
+
validate(): Promise<AuthValidationResult>;
|
|
102
|
+
/**
|
|
103
|
+
* Mark the current credential's validation state stale without deleting the
|
|
104
|
+
* credential (kept for recovery). Next validation cannot reuse a previous
|
|
105
|
+
* "valid" result (spec §9/§11). Call this when an API request returns 401.
|
|
106
|
+
*/
|
|
107
|
+
invalidate(): void;
|
|
46
108
|
onAuthStateChanged(listener: (info: HuaqiuUserInfo | null) => void): () => void;
|
|
47
109
|
}
|
|
48
110
|
interface HuaqiuAuthService {
|
|
49
111
|
auth: HuaqiuAuthApi;
|
|
50
|
-
/**
|
|
112
|
+
/**
|
|
113
|
+
* Node-only setters used by the webServer route handlers.
|
|
114
|
+
* NOTE: `service.invalidate()` is the FULL reset (logout: drops the pushed
|
|
115
|
+
* credential, persisted file and host cache). The capability-level
|
|
116
|
+
* `auth.invalidate()` is validation-scoped and keeps the credential.
|
|
117
|
+
*/
|
|
51
118
|
setCredentials(info: HuaqiuUserInfo): void;
|
|
52
119
|
invalidate(): void;
|
|
120
|
+
/**
|
|
121
|
+
* True when running in HQ Edge host mode (a host base URL was configured —
|
|
122
|
+
* overlay `config.hqEdgeBaseUrl` or `HQ_EDGE_BASE_URL`). The browser half
|
|
123
|
+
* reads this over the webServer config route to decide whether the sidebar
|
|
124
|
+
* login entrypoint is needed: in host mode EDA hands the credential to
|
|
125
|
+
* hq-edge, so the auth plugin's own login UI is suppressed.
|
|
126
|
+
*/
|
|
127
|
+
readonly hostMode: boolean;
|
|
53
128
|
}
|
|
54
129
|
//#endregion
|
|
55
130
|
//#region src/index.d.ts
|
package/lib/index.mjs
CHANGED
|
@@ -16,10 +16,18 @@ function resolveHostConfig(config, env = process.env) {
|
|
|
16
16
|
const parsed = Number.parseInt(ttlRaw, 10);
|
|
17
17
|
if (Number.isFinite(parsed) && parsed > 0) ttl = parsed;
|
|
18
18
|
}
|
|
19
|
+
const vTtlRaw = config?.validationTtlSeconds ?? env.HQ_EDGE_VALIDATION_TTL_SECONDS;
|
|
20
|
+
let validationTtlSeconds = 60;
|
|
21
|
+
if (typeof vTtlRaw === "number" && Number.isFinite(vTtlRaw) && vTtlRaw > 0) validationTtlSeconds = vTtlRaw;
|
|
22
|
+
else if (typeof vTtlRaw === "string" && vTtlRaw.trim().length > 0) {
|
|
23
|
+
const parsed = Number.parseInt(vTtlRaw, 10);
|
|
24
|
+
if (Number.isFinite(parsed) && parsed > 0) validationTtlSeconds = parsed;
|
|
25
|
+
}
|
|
19
26
|
return {
|
|
20
27
|
hqEdgeBaseUrl: baseUrl,
|
|
21
28
|
hostAuthPath,
|
|
22
|
-
hostSessionTtlSeconds: ttl
|
|
29
|
+
hostSessionTtlSeconds: ttl,
|
|
30
|
+
validationTtlSeconds
|
|
23
31
|
};
|
|
24
32
|
}
|
|
25
33
|
/**
|
|
@@ -85,6 +93,135 @@ function normalizeHostUser(data) {
|
|
|
85
93
|
...nickname ? { nickname } : {}
|
|
86
94
|
};
|
|
87
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Classifies the HTTP status of the validation request.
|
|
98
|
+
*
|
|
99
|
+
* 401 → unauthorized; 403 → forbidden; any other non-ok status (incl. 5xx) →
|
|
100
|
+
* unavailable. A 2xx body of `{ result: false }` → unauthorized (the endpoint
|
|
101
|
+
* does not distinguish expired vs revoked, so we use the generic
|
|
102
|
+
* `unauthorized` reason; local expiry is the only source of `expired`).
|
|
103
|
+
*/
|
|
104
|
+
function classifyStatus(status) {
|
|
105
|
+
if (status === 401) return {
|
|
106
|
+
status: "invalid",
|
|
107
|
+
reason: "unauthorized"
|
|
108
|
+
};
|
|
109
|
+
if (status === 403) return {
|
|
110
|
+
status: "invalid",
|
|
111
|
+
reason: "forbidden"
|
|
112
|
+
};
|
|
113
|
+
return {
|
|
114
|
+
status: "unavailable",
|
|
115
|
+
error: /* @__PURE__ */ new Error(`token validation HTTP ${status}`)
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
var TokenValidator = class {
|
|
119
|
+
ttlMs;
|
|
120
|
+
validateUrl;
|
|
121
|
+
fetchImpl;
|
|
122
|
+
now;
|
|
123
|
+
/** token → { result, at }; in-memory only, never persisted (spec §8). */
|
|
124
|
+
cache = /* @__PURE__ */ new Map();
|
|
125
|
+
/** token → in-flight promise; coalesces concurrent validate() calls (§16). */
|
|
126
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
127
|
+
constructor(options = {}) {
|
|
128
|
+
this.ttlMs = options.ttlMs ?? 6e4;
|
|
129
|
+
this.validateUrl = options.validateUrl ?? "https://www.eda.cn/api/token/validate";
|
|
130
|
+
this.fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
131
|
+
this.now = options.now ?? (() => Date.now());
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Cheap local expiry check — an optimization, not authoritative validation.
|
|
135
|
+
* Unknown expiry ⇒ not assumed invalid (spec §6).
|
|
136
|
+
*/
|
|
137
|
+
isLocallyExpired(expiresAt) {
|
|
138
|
+
if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt)) return false;
|
|
139
|
+
return expiresAt * 1e3 <= this.now();
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Validate a token. Cached within TTL; a single in-flight request is shared
|
|
143
|
+
* by concurrent callers. Never treats a network failure as "invalid".
|
|
144
|
+
*/
|
|
145
|
+
async validate(token, session) {
|
|
146
|
+
if (!token) return {
|
|
147
|
+
status: "invalid",
|
|
148
|
+
reason: "invalid"
|
|
149
|
+
};
|
|
150
|
+
if (this.isLocallyExpired(session?.expiresAt)) {
|
|
151
|
+
this.cache.delete(token);
|
|
152
|
+
return {
|
|
153
|
+
status: "invalid",
|
|
154
|
+
reason: "expired"
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const cached = this.cache.get(token);
|
|
158
|
+
if (cached && this.now() - cached.at < this.ttlMs) return cached.result;
|
|
159
|
+
const inFlight = this.inFlight.get(token);
|
|
160
|
+
if (inFlight) return inFlight;
|
|
161
|
+
const promise = this.validateRemotely(token);
|
|
162
|
+
this.inFlight.set(token, promise);
|
|
163
|
+
try {
|
|
164
|
+
const result = await promise;
|
|
165
|
+
this.cache.set(token, {
|
|
166
|
+
result,
|
|
167
|
+
at: this.now()
|
|
168
|
+
});
|
|
169
|
+
return result;
|
|
170
|
+
} finally {
|
|
171
|
+
this.inFlight.delete(token);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** Drop cached/in-flight validation for a token (or all when omitted). */
|
|
175
|
+
invalidate(token) {
|
|
176
|
+
if (token === void 0) {
|
|
177
|
+
this.cache.clear();
|
|
178
|
+
this.inFlight.clear();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
this.cache.delete(token);
|
|
182
|
+
this.inFlight.delete(token);
|
|
183
|
+
}
|
|
184
|
+
/** Resolve a currently-cached result (no remote call), or null. */
|
|
185
|
+
peek(token) {
|
|
186
|
+
const cached = this.cache.get(token);
|
|
187
|
+
if (!cached) return null;
|
|
188
|
+
if (this.now() - cached.at >= this.ttlMs) {
|
|
189
|
+
this.cache.delete(token);
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
return cached.result;
|
|
193
|
+
}
|
|
194
|
+
async validateRemotely(token) {
|
|
195
|
+
const url = `${this.validateUrl}?token=${encodeURIComponent(token)}`;
|
|
196
|
+
let res;
|
|
197
|
+
try {
|
|
198
|
+
res = await this.fetchImpl(url, {
|
|
199
|
+
method: "GET",
|
|
200
|
+
headers: { accept: "application/json" }
|
|
201
|
+
});
|
|
202
|
+
} catch (err) {
|
|
203
|
+
return {
|
|
204
|
+
status: "unavailable",
|
|
205
|
+
error: err
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
if (!res.ok) return classifyStatus(res.status);
|
|
209
|
+
let body;
|
|
210
|
+
try {
|
|
211
|
+
body = await res.json();
|
|
212
|
+
} catch (err) {
|
|
213
|
+
return {
|
|
214
|
+
status: "unavailable",
|
|
215
|
+
error: /* @__PURE__ */ new Error(`token validation: unparseable response (${String(err)})`)
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
if ((typeof body === "object" && body !== null ? body.result : void 0) === true) return { status: "valid" };
|
|
219
|
+
return {
|
|
220
|
+
status: "invalid",
|
|
221
|
+
reason: "unauthorized"
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
};
|
|
88
225
|
//#endregion
|
|
89
226
|
//#region src/service.ts
|
|
90
227
|
/**
|
|
@@ -124,10 +261,12 @@ function readPersisted() {
|
|
|
124
261
|
const token = typeof raw.token === "string" && raw.token.length > 0 ? raw.token : null;
|
|
125
262
|
if (!id || !token) return null;
|
|
126
263
|
const nickname = typeof raw.nickname === "string" && raw.nickname.length > 0 ? raw.nickname : void 0;
|
|
264
|
+
const expiresAt = typeof raw.expiresAt === "number" && Number.isFinite(raw.expiresAt) ? raw.expiresAt : void 0;
|
|
127
265
|
return {
|
|
128
266
|
id,
|
|
129
267
|
token,
|
|
130
|
-
...nickname ? { nickname } : {}
|
|
268
|
+
...nickname ? { nickname } : {},
|
|
269
|
+
...expiresAt !== void 0 ? { expiresAt } : {}
|
|
131
270
|
};
|
|
132
271
|
} catch {
|
|
133
272
|
return null;
|
|
@@ -153,19 +292,42 @@ var InMemoryHuaqiuAuthService = class {
|
|
|
153
292
|
current = null;
|
|
154
293
|
listeners = /* @__PURE__ */ new Set();
|
|
155
294
|
host;
|
|
295
|
+
validator;
|
|
296
|
+
/**
|
|
297
|
+
* True once the current credential has been rejected (API 401) or explicitly
|
|
298
|
+
* invalidated. Keeps the credential for recovery but makes `isAuthenticated()`
|
|
299
|
+
* short-circuit to false and forces a fresh remote validation next time.
|
|
300
|
+
*/
|
|
301
|
+
stale = false;
|
|
156
302
|
constructor(config, opts) {
|
|
157
303
|
const resolved = resolveHostConfig(config);
|
|
158
304
|
this.host = new HostSessionResolver(resolved.hqEdgeBaseUrl ?? "", resolved.hostAuthPath ?? "/api/v1/auth/token", (resolved.hostSessionTtlSeconds ?? 300) * 1e3, opts?.fetchImpl);
|
|
305
|
+
this.validator = new TokenValidator({
|
|
306
|
+
ttlMs: (resolved.validationTtlSeconds ?? 60) * 1e3,
|
|
307
|
+
fetchImpl: opts?.fetchImpl
|
|
308
|
+
});
|
|
309
|
+
this.hostMode = this.host.enabled;
|
|
159
310
|
}
|
|
311
|
+
/** Host mode is active iff a host base URL was configured (see HostSessionResolver.enabled). */
|
|
312
|
+
hostMode;
|
|
160
313
|
auth = {
|
|
161
|
-
isAuthenticated: () =>
|
|
314
|
+
isAuthenticated: async () => {
|
|
315
|
+
if (this.stale) return false;
|
|
316
|
+
return (await this.validateInternal()).status === "valid";
|
|
317
|
+
},
|
|
162
318
|
getAccessToken: async () => (await this.resolve())?.token ?? null,
|
|
163
319
|
getUserInfo: async () => this.resolve(),
|
|
164
320
|
login: async () => {},
|
|
165
321
|
logout: async () => this.invalidate(),
|
|
322
|
+
validate: () => this.validateInternal(),
|
|
323
|
+
invalidate: () => this.markStale(),
|
|
166
324
|
onAuthStateChanged: (listener) => this.on(listener)
|
|
167
325
|
};
|
|
168
|
-
/**
|
|
326
|
+
/**
|
|
327
|
+
* Spec §6.2 resolution order: host → pushed → persisted → null.
|
|
328
|
+
* Returns the credential regardless of validation state (recovery keeps the
|
|
329
|
+
* value; `validate()`/`isAuthenticated()` decide whether it is usable).
|
|
330
|
+
*/
|
|
169
331
|
async resolve() {
|
|
170
332
|
if (this.host.enabled) {
|
|
171
333
|
const host = await this.host.resolve();
|
|
@@ -174,8 +336,38 @@ var InMemoryHuaqiuAuthService = class {
|
|
|
174
336
|
if (this.current) return this.current;
|
|
175
337
|
return readPersisted();
|
|
176
338
|
}
|
|
339
|
+
/** Spec §7: resolve → local expiry → remote validation → update state. */
|
|
340
|
+
async validateInternal() {
|
|
341
|
+
const info = await this.resolve();
|
|
342
|
+
if (!info) {
|
|
343
|
+
this.stale = true;
|
|
344
|
+
return {
|
|
345
|
+
status: "invalid",
|
|
346
|
+
reason: "invalid"
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
if (this.validator.isLocallyExpired(info.expiresAt)) {
|
|
350
|
+
this.validator.invalidate(info.token);
|
|
351
|
+
this.stale = true;
|
|
352
|
+
return {
|
|
353
|
+
status: "invalid",
|
|
354
|
+
reason: "expired"
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
const result = await this.validator.validate(info.token, { expiresAt: info.expiresAt });
|
|
358
|
+
if (result.status === "valid") this.stale = false;
|
|
359
|
+
else if (result.status === "invalid") this.stale = true;
|
|
360
|
+
return result;
|
|
361
|
+
}
|
|
362
|
+
/** Validation-scoped invalidation: keep the credential, drop cached validity. */
|
|
363
|
+
markStale() {
|
|
364
|
+
this.stale = true;
|
|
365
|
+
this.validator.invalidate();
|
|
366
|
+
}
|
|
177
367
|
setCredentials(info) {
|
|
178
368
|
this.current = info;
|
|
369
|
+
this.stale = false;
|
|
370
|
+
this.validator.invalidate();
|
|
179
371
|
writePersisted(info);
|
|
180
372
|
this.emit();
|
|
181
373
|
}
|
|
@@ -183,6 +375,8 @@ var InMemoryHuaqiuAuthService = class {
|
|
|
183
375
|
const was = this.host.enabled || this.current !== null || readPersisted() !== null;
|
|
184
376
|
this.current = null;
|
|
185
377
|
this.host.clear();
|
|
378
|
+
this.stale = true;
|
|
379
|
+
this.validator.invalidate();
|
|
186
380
|
deletePersisted();
|
|
187
381
|
if (was) this.emit();
|
|
188
382
|
}
|
|
@@ -222,10 +416,12 @@ function normalizeUserInfo(data) {
|
|
|
222
416
|
const id = typeof data.userId === "string" && data.userId.length > 0 ? data.userId : typeof data.userId === "number" && Number.isFinite(data.userId) ? String(data.userId) : typeof data.id === "string" && data.id.length > 0 ? data.id : typeof data.id === "number" && Number.isFinite(data.id) ? String(data.id) : null;
|
|
223
417
|
if (!token || !id) return null;
|
|
224
418
|
const nickname = typeof data.nickname === "string" && data.nickname.length > 0 ? data.nickname : void 0;
|
|
419
|
+
const expiresAt = typeof data.expiresAt === "number" && Number.isFinite(data.expiresAt) ? data.expiresAt : void 0;
|
|
225
420
|
return {
|
|
226
421
|
id,
|
|
227
422
|
token,
|
|
228
|
-
...nickname ? { nickname } : {}
|
|
423
|
+
...nickname ? { nickname } : {},
|
|
424
|
+
...expiresAt !== void 0 ? { expiresAt } : {}
|
|
229
425
|
};
|
|
230
426
|
}
|
|
231
427
|
function createAuthHandler(service) {
|
|
@@ -252,11 +448,15 @@ function createAuthHandler(service) {
|
|
|
252
448
|
if (req.method === "GET" && pathname === `/api/v1/huaqiu/auth/session`) {
|
|
253
449
|
const user = await service.auth.getUserInfo();
|
|
254
450
|
sendJson(res, 200, {
|
|
255
|
-
authenticated: service.auth.isAuthenticated(),
|
|
451
|
+
authenticated: await service.auth.isAuthenticated(),
|
|
256
452
|
user
|
|
257
453
|
});
|
|
258
454
|
return;
|
|
259
455
|
}
|
|
456
|
+
if (req.method === "GET" && pathname === `/api/v1/huaqiu/auth/config`) {
|
|
457
|
+
sendJson(res, 200, { hostMode: service.hostMode });
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
260
460
|
sendJson(res, 404, { error: "not found" });
|
|
261
461
|
} catch (err) {
|
|
262
462
|
sendJson(res, 500, {
|
package/package.json
CHANGED
package/src/client/client.ts
CHANGED
|
@@ -39,6 +39,8 @@ export interface AuthClient {
|
|
|
39
39
|
restore(): Promise<void>
|
|
40
40
|
/** Re-push persisted credentials on demand (heals a reset/absent node half). */
|
|
41
41
|
syncNow(): Promise<void>
|
|
42
|
+
/** Browser→node transport (used to read host mode before UI registration). */
|
|
43
|
+
transport: AuthTransport
|
|
42
44
|
dispose(): void
|
|
43
45
|
}
|
|
44
46
|
|
|
@@ -144,6 +146,12 @@ export function createAuthClient(deps: AuthClientDeps): AuthClient {
|
|
|
144
146
|
auth,
|
|
145
147
|
handleMessageEvent,
|
|
146
148
|
restore,
|
|
149
|
+
/**
|
|
150
|
+
* Browser→node transport. Exposed so the client entry can read host mode
|
|
151
|
+
* (whether an HQ Edge host supplies the credential and the login UI should
|
|
152
|
+
* be suppressed) before registering the sidebar entrypoint.
|
|
153
|
+
*/
|
|
154
|
+
transport,
|
|
147
155
|
/**
|
|
148
156
|
* Re-push the persisted credential to the node half. Healing path: the
|
|
149
157
|
* node keeps auth in memory, so a `dsh web` restart (or a failed first
|
package/src/client/index.tsx
CHANGED
|
@@ -65,6 +65,7 @@ export function apply(ctx: ClientContext): () => void {
|
|
|
65
65
|
})
|
|
66
66
|
|
|
67
67
|
const disposers: Array<() => void> = []
|
|
68
|
+
let disposed = false
|
|
68
69
|
const disposeProvide = ctx.provide?.('huaqiuAuth', { auth: client.auth })
|
|
69
70
|
registerAuth(client.auth)
|
|
70
71
|
registerAuthSync(() => { void client.syncNow() })
|
|
@@ -85,15 +86,32 @@ export function apply(ctx: ClientContext): () => void {
|
|
|
85
86
|
document.removeEventListener('visibilitychange', sync)
|
|
86
87
|
})
|
|
87
88
|
|
|
89
|
+
/**
|
|
90
|
+
* In HQ Edge host mode (config.hqEdgeBaseUrl set on the node half), EDA
|
|
91
|
+
* launches hq-edge WITH the operator credential, so hq-edge — not this
|
|
92
|
+
* plugin — owns authentication for the session. The auth plugin's own login
|
|
93
|
+
* UI (the `sidebar.footer.action` entrypoint and the login toolviews) is
|
|
94
|
+
* therefore suppressed: it would be redundant and confusing next to the
|
|
95
|
+
* host-provided session. In standalone DSH (official integration) the
|
|
96
|
+
* sidebar entrypoint stays — it is the only login surface there.
|
|
97
|
+
*
|
|
98
|
+
* The mode is read from the node half over the plugin-owned webServer route
|
|
99
|
+
* (async), so registration is deferred until it answers; the returned
|
|
100
|
+
* disposer still drains anything registered later.
|
|
101
|
+
*/
|
|
88
102
|
const slots = ctx.slots
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
103
|
+
void client.transport.fetchHostMode().then((hostMode) => {
|
|
104
|
+
if (disposed || hostMode) return
|
|
105
|
+
if (slots && typeof slots.inject === 'function' && typeof slots.register === 'function') {
|
|
106
|
+
for (const toolName of AUTH_TOOL_NAMES) {
|
|
107
|
+
disposers.push(slots.inject('tool.call.toolview', () => slots.register({ name: 'tool.call.toolview', key: toolName }, HuaqiuToolView) as () => void))
|
|
108
|
+
}
|
|
109
|
+
disposers.push(slots.inject('sidebar.footer.action', () => slots.register({ name: 'sidebar.footer.action', id: 'huaqiu-auth' }, HuaqiuAuthSidebarAction) as () => void))
|
|
92
110
|
}
|
|
93
|
-
|
|
94
|
-
}
|
|
111
|
+
})
|
|
95
112
|
|
|
96
113
|
return () => {
|
|
114
|
+
disposed = true
|
|
97
115
|
for (const dispose of disposers) {
|
|
98
116
|
try {
|
|
99
117
|
dispose()
|
package/src/client/transport.ts
CHANGED
|
@@ -9,6 +9,12 @@ import type { AuthTokenPayload } from './lib.js'
|
|
|
9
9
|
export interface AuthTransport {
|
|
10
10
|
pushSession(info: AuthTokenPayload): Promise<void>
|
|
11
11
|
pushLogout(): Promise<void>
|
|
12
|
+
/**
|
|
13
|
+
* Whether the plugin runs under an HQ Edge host. In host mode hq-edge already
|
|
14
|
+
* holds the operator credential (EDA hands it over on launch), so the
|
|
15
|
+
* browser half's own login UI (sidebar entrypoint) is suppressed.
|
|
16
|
+
*/
|
|
17
|
+
fetchHostMode(): Promise<boolean>
|
|
12
18
|
}
|
|
13
19
|
|
|
14
20
|
export function createWebServerAuthTransport(
|
|
@@ -24,6 +30,7 @@ export function createWebServerAuthTransport(
|
|
|
24
30
|
token: info.token,
|
|
25
31
|
userId: info.id,
|
|
26
32
|
...(info.nickname !== undefined ? { nickname: info.nickname } : {}),
|
|
33
|
+
...(info.expiresAt !== undefined ? { expiresAt: info.expiresAt } : {}),
|
|
27
34
|
}),
|
|
28
35
|
})
|
|
29
36
|
if (!res.ok) throw new Error(`auth push failed: HTTP ${res.status}`)
|
|
@@ -32,5 +39,21 @@ export function createWebServerAuthTransport(
|
|
|
32
39
|
const res = await doFetch(`${base}/logout`, { method: 'POST' })
|
|
33
40
|
if (!res.ok) throw new Error(`auth logout push failed: HTTP ${res.status}`)
|
|
34
41
|
},
|
|
42
|
+
async fetchHostMode() {
|
|
43
|
+
try {
|
|
44
|
+
const res = await doFetch(`${base}/config`, {
|
|
45
|
+
method: 'GET',
|
|
46
|
+
headers: { accept: 'application/json' },
|
|
47
|
+
})
|
|
48
|
+
if (!res.ok) return false
|
|
49
|
+
const body = await res.json() as { hostMode?: unknown }
|
|
50
|
+
return body.hostMode === true
|
|
51
|
+
} catch {
|
|
52
|
+
// Offline/same-origin failure: fall back to standalone (show the login
|
|
53
|
+
// entrypoint) rather than hiding it — a login UI is never a security
|
|
54
|
+
// regression, but a missing one in standalone would lock the user out.
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
},
|
|
35
58
|
}
|
|
36
59
|
}
|
package/src/host.ts
CHANGED
|
@@ -27,10 +27,13 @@ export interface HuaqiuAuthConfig {
|
|
|
27
27
|
hostAuthPath?: string
|
|
28
28
|
/** Seconds a host session is reused before re-fetching. Default 300. */
|
|
29
29
|
hostSessionTtlSeconds?: number
|
|
30
|
+
/** Seconds remote token-validation results are cached. Default 60. */
|
|
31
|
+
validationTtlSeconds?: number
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
export const DEFAULT_HOST_AUTH_PATH = '/api/v1/auth/token'
|
|
33
35
|
export const DEFAULT_HOST_TTL_SECONDS = 300
|
|
36
|
+
export const DEFAULT_VALIDATION_TTL_SECONDS = 60
|
|
34
37
|
|
|
35
38
|
/**
|
|
36
39
|
* Resolve the effective config: overlay `config` (highest) > env (safety net
|
|
@@ -55,7 +58,20 @@ export function resolveHostConfig(
|
|
|
55
58
|
const parsed = Number.parseInt(ttlRaw, 10)
|
|
56
59
|
if (Number.isFinite(parsed) && parsed > 0) ttl = parsed
|
|
57
60
|
}
|
|
58
|
-
|
|
61
|
+
const vTtlRaw = config?.validationTtlSeconds ?? env.HQ_EDGE_VALIDATION_TTL_SECONDS
|
|
62
|
+
let validationTtlSeconds = DEFAULT_VALIDATION_TTL_SECONDS
|
|
63
|
+
if (typeof vTtlRaw === 'number' && Number.isFinite(vTtlRaw) && vTtlRaw > 0) {
|
|
64
|
+
validationTtlSeconds = vTtlRaw
|
|
65
|
+
} else if (typeof vTtlRaw === 'string' && vTtlRaw.trim().length > 0) {
|
|
66
|
+
const parsed = Number.parseInt(vTtlRaw, 10)
|
|
67
|
+
if (Number.isFinite(parsed) && parsed > 0) validationTtlSeconds = parsed
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
hqEdgeBaseUrl: baseUrl,
|
|
71
|
+
hostAuthPath,
|
|
72
|
+
hostSessionTtlSeconds: ttl,
|
|
73
|
+
validationTtlSeconds,
|
|
74
|
+
}
|
|
59
75
|
}
|
|
60
76
|
|
|
61
77
|
export interface HostSession {
|
package/src/routes.ts
CHANGED
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
* HTTP adapter: receives the browser-pushed credentials and serves the node
|
|
3
3
|
* auth state (probe / boot restore). Same-origin through `ctx.webServer`:
|
|
4
4
|
*
|
|
5
|
-
* POST /api/v1/huaqiu/auth/session body { token,
|
|
5
|
+
* POST /api/v1/huaqiu/auth/session body { token, userId, nickname?, expiresAt? } → cache set
|
|
6
6
|
* POST /api/v1/huaqiu/auth/logout → cache cleared
|
|
7
7
|
* GET /api/v1/huaqiu/auth/session → { authenticated, user }
|
|
8
|
+
* GET /api/v1/huaqiu/auth/config → { hostMode }
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* `config` tells the browser half whether it is running under an HQ Edge host
|
|
11
|
+
* (hq-edge passes the operator credential to hq-edge itself, so the sidebar
|
|
12
|
+
* login entrypoint is suppressed). These routes are the browser→node transport
|
|
13
|
+
* for Phase 0A (start-p0.md §4: smallest supported extension point —
|
|
14
|
+
* `apiProxy`'s dispatch table is closed, so a plugin-owned `webServer` route is
|
|
15
|
+
* the documented channel).
|
|
12
16
|
*/
|
|
13
17
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
14
18
|
import type { HuaqiuAuthService, HuaqiuUserInfo } from './service.js'
|
|
@@ -41,7 +45,15 @@ function normalizeUserInfo(data: Record<string, unknown>): HuaqiuUserInfo | null
|
|
|
41
45
|
: null
|
|
42
46
|
if (!token || !id) return null
|
|
43
47
|
const nickname = typeof data.nickname === 'string' && data.nickname.length > 0 ? data.nickname : undefined
|
|
44
|
-
|
|
48
|
+
const expiresAt = typeof data.expiresAt === 'number' && Number.isFinite(data.expiresAt)
|
|
49
|
+
? data.expiresAt
|
|
50
|
+
: undefined
|
|
51
|
+
return {
|
|
52
|
+
id,
|
|
53
|
+
token,
|
|
54
|
+
...(nickname ? { nickname } : {}),
|
|
55
|
+
...(expiresAt !== undefined ? { expiresAt } : {}),
|
|
56
|
+
}
|
|
45
57
|
}
|
|
46
58
|
|
|
47
59
|
export function createAuthHandler(service: HuaqiuAuthService): AuthHandler {
|
|
@@ -71,11 +83,16 @@ export function createAuthHandler(service: HuaqiuAuthService): AuthHandler {
|
|
|
71
83
|
|
|
72
84
|
if (req.method === 'GET' && pathname === `${AUTH_ROUTE_PREFIX}/session`) {
|
|
73
85
|
const user = await service.auth.getUserInfo()
|
|
74
|
-
const authenticated = service.auth.isAuthenticated()
|
|
86
|
+
const authenticated = await service.auth.isAuthenticated()
|
|
75
87
|
sendJson(res, 200, { authenticated, user })
|
|
76
88
|
return
|
|
77
89
|
}
|
|
78
90
|
|
|
91
|
+
if (req.method === 'GET' && pathname === `${AUTH_ROUTE_PREFIX}/config`) {
|
|
92
|
+
sendJson(res, 200, { hostMode: service.hostMode })
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
|
|
79
96
|
sendJson(res, 404, { error: 'not found' })
|
|
80
97
|
} catch (err) {
|
|
81
98
|
sendJson(res, 500, { error: 'internal error', detail: String(err) })
|