@huaqiu/dsh-auth 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +2 -1
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +69 -2
- package/lib/index.mjs +206 -7
- package/package.json +1 -1
- package/src/client/transport.ts +1 -0
- package/src/host.ts +17 -1
- package/src/routes.ts +11 -3
- package/src/service.ts +101 -6
- package/src/validation.ts +182 -0
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,39 +292,97 @@ 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
|
+
});
|
|
159
309
|
this.hostMode = this.host.enabled;
|
|
160
310
|
}
|
|
161
311
|
/** Host mode is active iff a host base URL was configured (see HostSessionResolver.enabled). */
|
|
162
312
|
hostMode;
|
|
163
313
|
auth = {
|
|
164
|
-
isAuthenticated: () =>
|
|
314
|
+
isAuthenticated: async () => {
|
|
315
|
+
if (this.stale) return false;
|
|
316
|
+
return (await this.validateInternal()).status === "valid";
|
|
317
|
+
},
|
|
165
318
|
getAccessToken: async () => (await this.resolve())?.token ?? null,
|
|
166
319
|
getUserInfo: async () => this.resolve(),
|
|
167
320
|
login: async () => {},
|
|
168
321
|
logout: async () => this.invalidate(),
|
|
322
|
+
validate: () => this.validateInternal(),
|
|
323
|
+
invalidate: () => this.markStale(),
|
|
169
324
|
onAuthStateChanged: (listener) => this.on(listener)
|
|
170
325
|
};
|
|
171
|
-
/**
|
|
326
|
+
/**
|
|
327
|
+
* Spec §6.2 resolution order: host → pushed → persisted → null.
|
|
328
|
+
*
|
|
329
|
+
* Host mode is STRICT: an enabled host is the single source of truth. If the
|
|
330
|
+
* host yields no credential, the operator is unauthenticated — we must NOT
|
|
331
|
+
* fall back to a pushed/persisted credential left behind by an earlier
|
|
332
|
+
* standalone (auth.eda.cn) login. Otherwise a stale `~/.dsh/auth/session.json`
|
|
333
|
+
* would let the DSH plugins keep calling the backend while hq-edge itself has
|
|
334
|
+
* no auth info. The standalone fallbacks only apply when no host is configured.
|
|
335
|
+
*/
|
|
172
336
|
async resolve() {
|
|
173
337
|
if (this.host.enabled) {
|
|
174
338
|
const host = await this.host.resolve();
|
|
175
339
|
if (host) return toUserInfo(host);
|
|
340
|
+
return null;
|
|
176
341
|
}
|
|
177
342
|
if (this.current) return this.current;
|
|
178
343
|
return readPersisted();
|
|
179
344
|
}
|
|
345
|
+
/** Spec §7: resolve → local expiry → remote validation → update state. */
|
|
346
|
+
async validateInternal() {
|
|
347
|
+
const info = await this.resolve();
|
|
348
|
+
if (!info) {
|
|
349
|
+
this.stale = true;
|
|
350
|
+
return {
|
|
351
|
+
status: "invalid",
|
|
352
|
+
reason: "invalid"
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
if (this.validator.isLocallyExpired(info.expiresAt)) {
|
|
356
|
+
this.validator.invalidate(info.token);
|
|
357
|
+
this.stale = true;
|
|
358
|
+
return {
|
|
359
|
+
status: "invalid",
|
|
360
|
+
reason: "expired"
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
const result = await this.validator.validate(info.token, { expiresAt: info.expiresAt });
|
|
364
|
+
if (result.status === "valid") this.stale = false;
|
|
365
|
+
else if (result.status === "invalid") this.stale = true;
|
|
366
|
+
return result;
|
|
367
|
+
}
|
|
368
|
+
/** Validation-scoped invalidation: keep the credential, drop cached validity. */
|
|
369
|
+
markStale() {
|
|
370
|
+
this.stale = true;
|
|
371
|
+
this.validator.invalidate();
|
|
372
|
+
}
|
|
180
373
|
setCredentials(info) {
|
|
181
374
|
this.current = info;
|
|
182
|
-
|
|
375
|
+
this.stale = false;
|
|
376
|
+
this.validator.invalidate();
|
|
377
|
+
if (!this.hostMode) writePersisted(info);
|
|
183
378
|
this.emit();
|
|
184
379
|
}
|
|
185
380
|
invalidate() {
|
|
186
381
|
const was = this.host.enabled || this.current !== null || readPersisted() !== null;
|
|
187
382
|
this.current = null;
|
|
188
383
|
this.host.clear();
|
|
384
|
+
this.stale = true;
|
|
385
|
+
this.validator.invalidate();
|
|
189
386
|
deletePersisted();
|
|
190
387
|
if (was) this.emit();
|
|
191
388
|
}
|
|
@@ -225,10 +422,12 @@ function normalizeUserInfo(data) {
|
|
|
225
422
|
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;
|
|
226
423
|
if (!token || !id) return null;
|
|
227
424
|
const nickname = typeof data.nickname === "string" && data.nickname.length > 0 ? data.nickname : void 0;
|
|
425
|
+
const expiresAt = typeof data.expiresAt === "number" && Number.isFinite(data.expiresAt) ? data.expiresAt : void 0;
|
|
228
426
|
return {
|
|
229
427
|
id,
|
|
230
428
|
token,
|
|
231
|
-
...nickname ? { nickname } : {}
|
|
429
|
+
...nickname ? { nickname } : {},
|
|
430
|
+
...expiresAt !== void 0 ? { expiresAt } : {}
|
|
232
431
|
};
|
|
233
432
|
}
|
|
234
433
|
function createAuthHandler(service) {
|
|
@@ -255,7 +454,7 @@ function createAuthHandler(service) {
|
|
|
255
454
|
if (req.method === "GET" && pathname === `/api/v1/huaqiu/auth/session`) {
|
|
256
455
|
const user = await service.auth.getUserInfo();
|
|
257
456
|
sendJson(res, 200, {
|
|
258
|
-
authenticated: service.auth.isAuthenticated(),
|
|
457
|
+
authenticated: await service.auth.isAuthenticated(),
|
|
259
458
|
user
|
|
260
459
|
});
|
|
261
460
|
return;
|
package/package.json
CHANGED
package/src/client/transport.ts
CHANGED
|
@@ -30,6 +30,7 @@ export function createWebServerAuthTransport(
|
|
|
30
30
|
token: info.token,
|
|
31
31
|
userId: info.id,
|
|
32
32
|
...(info.nickname !== undefined ? { nickname: info.nickname } : {}),
|
|
33
|
+
...(info.expiresAt !== undefined ? { expiresAt: info.expiresAt } : {}),
|
|
33
34
|
}),
|
|
34
35
|
})
|
|
35
36
|
if (!res.ok) throw new Error(`auth push failed: HTTP ${res.status}`)
|
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,7 +2,7 @@
|
|
|
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
8
|
* GET /api/v1/huaqiu/auth/config → { hostMode }
|
|
@@ -45,7 +45,15 @@ function normalizeUserInfo(data: Record<string, unknown>): HuaqiuUserInfo | null
|
|
|
45
45
|
: null
|
|
46
46
|
if (!token || !id) return null
|
|
47
47
|
const nickname = typeof data.nickname === 'string' && data.nickname.length > 0 ? data.nickname : undefined
|
|
48
|
-
|
|
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
|
+
}
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
export function createAuthHandler(service: HuaqiuAuthService): AuthHandler {
|
|
@@ -75,7 +83,7 @@ export function createAuthHandler(service: HuaqiuAuthService): AuthHandler {
|
|
|
75
83
|
|
|
76
84
|
if (req.method === 'GET' && pathname === `${AUTH_ROUTE_PREFIX}/session`) {
|
|
77
85
|
const user = await service.auth.getUserInfo()
|
|
78
|
-
const authenticated = service.auth.isAuthenticated()
|
|
86
|
+
const authenticated = await service.auth.isAuthenticated()
|
|
79
87
|
sendJson(res, 200, { authenticated, user })
|
|
80
88
|
return
|
|
81
89
|
}
|
package/src/service.ts
CHANGED
|
@@ -29,26 +29,52 @@ import {
|
|
|
29
29
|
type HuaqiuAuthConfig,
|
|
30
30
|
type ResolvedHostUser,
|
|
31
31
|
} from './host.js'
|
|
32
|
+
import { TokenValidator, type AuthValidationResult } from './validation.js'
|
|
32
33
|
|
|
33
34
|
export interface HuaqiuUserInfo {
|
|
34
35
|
id: string
|
|
35
36
|
token: string
|
|
36
37
|
nickname?: string
|
|
38
|
+
/** Unix seconds; known for browser-pushed sessions (auth.eda.cn window). */
|
|
39
|
+
expiresAt?: number
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
export interface HuaqiuAuthApi {
|
|
40
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Authoritative async check: a credential exists AND is known to be valid
|
|
45
|
+
* (local expiry + cached remote validation). Never a mere token-presence
|
|
46
|
+
* check — a host token supplied by hq-edge is not assumed valid just because
|
|
47
|
+
* it exists (spec §10). Short-circuits to `false` after `invalidate()` until
|
|
48
|
+
* re-validated or a fresh credential arrives.
|
|
49
|
+
*/
|
|
50
|
+
isAuthenticated(): Promise<boolean>
|
|
41
51
|
getAccessToken(): Promise<string | null>
|
|
42
52
|
getUserInfo(): Promise<HuaqiuUserInfo | null>
|
|
43
53
|
/** Node-side no-op: login always happens in the browser. */
|
|
44
54
|
login(): Promise<void>
|
|
45
55
|
logout(): Promise<void>
|
|
56
|
+
/**
|
|
57
|
+
* Single authoritative validation path (spec §7). Works identically for
|
|
58
|
+
* standalone and host credentials; never depends on hq-edge.
|
|
59
|
+
*/
|
|
60
|
+
validate(): Promise<AuthValidationResult>
|
|
61
|
+
/**
|
|
62
|
+
* Mark the current credential's validation state stale without deleting the
|
|
63
|
+
* credential (kept for recovery). Next validation cannot reuse a previous
|
|
64
|
+
* "valid" result (spec §9/§11). Call this when an API request returns 401.
|
|
65
|
+
*/
|
|
66
|
+
invalidate(): void
|
|
46
67
|
onAuthStateChanged(listener: (info: HuaqiuUserInfo | null) => void): () => void
|
|
47
68
|
}
|
|
48
69
|
|
|
49
70
|
export interface HuaqiuAuthService {
|
|
50
71
|
auth: HuaqiuAuthApi
|
|
51
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* Node-only setters used by the webServer route handlers.
|
|
74
|
+
* NOTE: `service.invalidate()` is the FULL reset (logout: drops the pushed
|
|
75
|
+
* credential, persisted file and host cache). The capability-level
|
|
76
|
+
* `auth.invalidate()` is validation-scoped and keeps the credential.
|
|
77
|
+
*/
|
|
52
78
|
setCredentials(info: HuaqiuUserInfo): void
|
|
53
79
|
invalidate(): void
|
|
54
80
|
/**
|
|
@@ -79,7 +105,15 @@ function readPersisted(): HuaqiuUserInfo | null {
|
|
|
79
105
|
const nickname = typeof raw.nickname === 'string' && raw.nickname.length > 0
|
|
80
106
|
? raw.nickname
|
|
81
107
|
: undefined
|
|
82
|
-
|
|
108
|
+
const expiresAt = typeof raw.expiresAt === 'number' && Number.isFinite(raw.expiresAt)
|
|
109
|
+
? raw.expiresAt
|
|
110
|
+
: undefined
|
|
111
|
+
return {
|
|
112
|
+
id,
|
|
113
|
+
token,
|
|
114
|
+
...(nickname ? { nickname } : {}),
|
|
115
|
+
...(expiresAt !== undefined ? { expiresAt } : {}),
|
|
116
|
+
}
|
|
83
117
|
} catch {
|
|
84
118
|
return null
|
|
85
119
|
}
|
|
@@ -111,6 +145,13 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
|
|
|
111
145
|
private current: HuaqiuUserInfo | null = null
|
|
112
146
|
private listeners = new Set<(info: HuaqiuUserInfo | null) => void>()
|
|
113
147
|
private readonly host: HostSessionResolver
|
|
148
|
+
private readonly validator: TokenValidator
|
|
149
|
+
/**
|
|
150
|
+
* True once the current credential has been rejected (API 401) or explicitly
|
|
151
|
+
* invalidated. Keeps the credential for recovery but makes `isAuthenticated()`
|
|
152
|
+
* short-circuit to false and forces a fresh remote validation next time.
|
|
153
|
+
*/
|
|
154
|
+
private stale = false
|
|
114
155
|
|
|
115
156
|
constructor(
|
|
116
157
|
config?: Partial<HuaqiuAuthConfig> | null,
|
|
@@ -123,6 +164,10 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
|
|
|
123
164
|
(resolved.hostSessionTtlSeconds ?? 300) * 1000,
|
|
124
165
|
opts?.fetchImpl,
|
|
125
166
|
)
|
|
167
|
+
this.validator = new TokenValidator({
|
|
168
|
+
ttlMs: (resolved.validationTtlSeconds ?? 60) * 1000,
|
|
169
|
+
fetchImpl: opts?.fetchImpl,
|
|
170
|
+
})
|
|
126
171
|
this.hostMode = this.host.enabled
|
|
127
172
|
}
|
|
128
173
|
|
|
@@ -130,29 +175,77 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
|
|
|
130
175
|
readonly hostMode: boolean
|
|
131
176
|
|
|
132
177
|
readonly auth: HuaqiuAuthApi = {
|
|
133
|
-
isAuthenticated: () =>
|
|
178
|
+
isAuthenticated: async () => {
|
|
179
|
+
if (this.stale) return false
|
|
180
|
+
return (await this.validateInternal()).status === 'valid'
|
|
181
|
+
},
|
|
134
182
|
getAccessToken: async () => (await this.resolve())?.token ?? null,
|
|
135
183
|
getUserInfo: async () => this.resolve(),
|
|
136
184
|
login: async () => {
|
|
137
185
|
/* login is a browser action */
|
|
138
186
|
},
|
|
139
187
|
logout: async () => this.invalidate(),
|
|
188
|
+
validate: () => this.validateInternal(),
|
|
189
|
+
invalidate: () => this.markStale(),
|
|
140
190
|
onAuthStateChanged: (listener) => this.on(listener),
|
|
141
191
|
}
|
|
142
192
|
|
|
143
|
-
/**
|
|
193
|
+
/**
|
|
194
|
+
* Spec §6.2 resolution order: host → pushed → persisted → null.
|
|
195
|
+
*
|
|
196
|
+
* Host mode is STRICT: an enabled host is the single source of truth. If the
|
|
197
|
+
* host yields no credential, the operator is unauthenticated — we must NOT
|
|
198
|
+
* fall back to a pushed/persisted credential left behind by an earlier
|
|
199
|
+
* standalone (auth.eda.cn) login. Otherwise a stale `~/.dsh/auth/session.json`
|
|
200
|
+
* would let the DSH plugins keep calling the backend while hq-edge itself has
|
|
201
|
+
* no auth info. The standalone fallbacks only apply when no host is configured.
|
|
202
|
+
*/
|
|
144
203
|
private async resolve(): Promise<HuaqiuUserInfo | null> {
|
|
145
204
|
if (this.host.enabled) {
|
|
146
205
|
const host = await this.host.resolve()
|
|
147
206
|
if (host) return toUserInfo(host)
|
|
207
|
+
return null
|
|
148
208
|
}
|
|
149
209
|
if (this.current) return this.current
|
|
150
210
|
return readPersisted()
|
|
151
211
|
}
|
|
152
212
|
|
|
213
|
+
/** Spec §7: resolve → local expiry → remote validation → update state. */
|
|
214
|
+
private async validateInternal(): Promise<AuthValidationResult> {
|
|
215
|
+
const info = await this.resolve()
|
|
216
|
+
if (!info) {
|
|
217
|
+
this.stale = true
|
|
218
|
+
return { status: 'invalid', reason: 'invalid' }
|
|
219
|
+
}
|
|
220
|
+
// Local expiry is an optimization; remote validation stays authoritative.
|
|
221
|
+
if (this.validator.isLocallyExpired(info.expiresAt)) {
|
|
222
|
+
this.validator.invalidate(info.token)
|
|
223
|
+
this.stale = true
|
|
224
|
+
return { status: 'invalid', reason: 'expired' }
|
|
225
|
+
}
|
|
226
|
+
const result = await this.validator.validate(info.token, { expiresAt: info.expiresAt })
|
|
227
|
+
if (result.status === 'valid') this.stale = false
|
|
228
|
+
else if (result.status === 'invalid') this.stale = true
|
|
229
|
+
// 'unavailable' leaves the stale flag untouched — a network blip never
|
|
230
|
+
// declares the credential invalid (spec §17).
|
|
231
|
+
return result
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Validation-scoped invalidation: keep the credential, drop cached validity. */
|
|
235
|
+
private markStale(): void {
|
|
236
|
+
this.stale = true
|
|
237
|
+
this.validator.invalidate()
|
|
238
|
+
}
|
|
239
|
+
|
|
153
240
|
setCredentials(info: HuaqiuUserInfo): void {
|
|
154
241
|
this.current = info
|
|
155
|
-
|
|
242
|
+
this.stale = false
|
|
243
|
+
this.validator.invalidate()
|
|
244
|
+
// Never persist to the standalone store while running under a host: the
|
|
245
|
+
// host (hq-edge) owns the credential in host mode, and a stale standalone
|
|
246
|
+
// session must not leak across modes. Persistence is only meaningful in
|
|
247
|
+
// standalone (silent-login restore).
|
|
248
|
+
if (!this.hostMode) void writePersisted(info)
|
|
156
249
|
this.emit()
|
|
157
250
|
}
|
|
158
251
|
|
|
@@ -160,6 +253,8 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
|
|
|
160
253
|
const was = this.host.enabled || this.current !== null || readPersisted() !== null
|
|
161
254
|
this.current = null
|
|
162
255
|
this.host.clear()
|
|
256
|
+
this.stale = true
|
|
257
|
+
this.validator.invalidate()
|
|
163
258
|
deletePersisted()
|
|
164
259
|
if (was) this.emit()
|
|
165
260
|
}
|