@huaqiu/dsh-auth 0.1.2 → 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 +2 -1
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +69 -2
- package/lib/index.mjs +199 -6
- 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 +90 -5
- 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,22 +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
|
+
});
|
|
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
|
+
* Returns the credential regardless of validation state (recovery keeps the
|
|
329
|
+
* value; `validate()`/`isAuthenticated()` decide whether it is usable).
|
|
330
|
+
*/
|
|
172
331
|
async resolve() {
|
|
173
332
|
if (this.host.enabled) {
|
|
174
333
|
const host = await this.host.resolve();
|
|
@@ -177,8 +336,38 @@ var InMemoryHuaqiuAuthService = class {
|
|
|
177
336
|
if (this.current) return this.current;
|
|
178
337
|
return readPersisted();
|
|
179
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
|
+
}
|
|
180
367
|
setCredentials(info) {
|
|
181
368
|
this.current = info;
|
|
369
|
+
this.stale = false;
|
|
370
|
+
this.validator.invalidate();
|
|
182
371
|
writePersisted(info);
|
|
183
372
|
this.emit();
|
|
184
373
|
}
|
|
@@ -186,6 +375,8 @@ var InMemoryHuaqiuAuthService = class {
|
|
|
186
375
|
const was = this.host.enabled || this.current !== null || readPersisted() !== null;
|
|
187
376
|
this.current = null;
|
|
188
377
|
this.host.clear();
|
|
378
|
+
this.stale = true;
|
|
379
|
+
this.validator.invalidate();
|
|
189
380
|
deletePersisted();
|
|
190
381
|
if (was) this.emit();
|
|
191
382
|
}
|
|
@@ -225,10 +416,12 @@ function normalizeUserInfo(data) {
|
|
|
225
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;
|
|
226
417
|
if (!token || !id) return null;
|
|
227
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;
|
|
228
420
|
return {
|
|
229
421
|
id,
|
|
230
422
|
token,
|
|
231
|
-
...nickname ? { nickname } : {}
|
|
423
|
+
...nickname ? { nickname } : {},
|
|
424
|
+
...expiresAt !== void 0 ? { expiresAt } : {}
|
|
232
425
|
};
|
|
233
426
|
}
|
|
234
427
|
function createAuthHandler(service) {
|
|
@@ -255,7 +448,7 @@ function createAuthHandler(service) {
|
|
|
255
448
|
if (req.method === "GET" && pathname === `/api/v1/huaqiu/auth/session`) {
|
|
256
449
|
const user = await service.auth.getUserInfo();
|
|
257
450
|
sendJson(res, 200, {
|
|
258
|
-
authenticated: service.auth.isAuthenticated(),
|
|
451
|
+
authenticated: await service.auth.isAuthenticated(),
|
|
259
452
|
user
|
|
260
453
|
});
|
|
261
454
|
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,17 +175,26 @@ 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
|
+
* Returns the credential regardless of validation state (recovery keeps the
|
|
196
|
+
* value; `validate()`/`isAuthenticated()` decide whether it is usable).
|
|
197
|
+
*/
|
|
144
198
|
private async resolve(): Promise<HuaqiuUserInfo | null> {
|
|
145
199
|
if (this.host.enabled) {
|
|
146
200
|
const host = await this.host.resolve()
|
|
@@ -150,8 +204,37 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
|
|
|
150
204
|
return readPersisted()
|
|
151
205
|
}
|
|
152
206
|
|
|
207
|
+
/** Spec §7: resolve → local expiry → remote validation → update state. */
|
|
208
|
+
private async validateInternal(): Promise<AuthValidationResult> {
|
|
209
|
+
const info = await this.resolve()
|
|
210
|
+
if (!info) {
|
|
211
|
+
this.stale = true
|
|
212
|
+
return { status: 'invalid', reason: 'invalid' }
|
|
213
|
+
}
|
|
214
|
+
// Local expiry is an optimization; remote validation stays authoritative.
|
|
215
|
+
if (this.validator.isLocallyExpired(info.expiresAt)) {
|
|
216
|
+
this.validator.invalidate(info.token)
|
|
217
|
+
this.stale = true
|
|
218
|
+
return { status: 'invalid', reason: 'expired' }
|
|
219
|
+
}
|
|
220
|
+
const result = await this.validator.validate(info.token, { expiresAt: info.expiresAt })
|
|
221
|
+
if (result.status === 'valid') this.stale = false
|
|
222
|
+
else if (result.status === 'invalid') this.stale = true
|
|
223
|
+
// 'unavailable' leaves the stale flag untouched — a network blip never
|
|
224
|
+
// declares the credential invalid (spec §17).
|
|
225
|
+
return result
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Validation-scoped invalidation: keep the credential, drop cached validity. */
|
|
229
|
+
private markStale(): void {
|
|
230
|
+
this.stale = true
|
|
231
|
+
this.validator.invalidate()
|
|
232
|
+
}
|
|
233
|
+
|
|
153
234
|
setCredentials(info: HuaqiuUserInfo): void {
|
|
154
235
|
this.current = info
|
|
236
|
+
this.stale = false
|
|
237
|
+
this.validator.invalidate()
|
|
155
238
|
void writePersisted(info)
|
|
156
239
|
this.emit()
|
|
157
240
|
}
|
|
@@ -160,6 +243,8 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
|
|
|
160
243
|
const was = this.host.enabled || this.current !== null || readPersisted() !== null
|
|
161
244
|
this.current = null
|
|
162
245
|
this.host.clear()
|
|
246
|
+
this.stale = true
|
|
247
|
+
this.validator.invalidate()
|
|
163
248
|
deletePersisted()
|
|
164
249
|
if (was) this.emit()
|
|
165
250
|
}
|