@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/src/service.ts
CHANGED
|
@@ -29,28 +29,62 @@ 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
|
|
80
|
+
/**
|
|
81
|
+
* True when running in HQ Edge host mode (a host base URL was configured —
|
|
82
|
+
* overlay `config.hqEdgeBaseUrl` or `HQ_EDGE_BASE_URL`). The browser half
|
|
83
|
+
* reads this over the webServer config route to decide whether the sidebar
|
|
84
|
+
* login entrypoint is needed: in host mode EDA hands the credential to
|
|
85
|
+
* hq-edge, so the auth plugin's own login UI is suppressed.
|
|
86
|
+
*/
|
|
87
|
+
readonly hostMode: boolean
|
|
54
88
|
}
|
|
55
89
|
|
|
56
90
|
const PERSIST_FILE = 'session.json'
|
|
@@ -71,7 +105,15 @@ function readPersisted(): HuaqiuUserInfo | null {
|
|
|
71
105
|
const nickname = typeof raw.nickname === 'string' && raw.nickname.length > 0
|
|
72
106
|
? raw.nickname
|
|
73
107
|
: undefined
|
|
74
|
-
|
|
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
|
+
}
|
|
75
117
|
} catch {
|
|
76
118
|
return null
|
|
77
119
|
}
|
|
@@ -103,6 +145,13 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
|
|
|
103
145
|
private current: HuaqiuUserInfo | null = null
|
|
104
146
|
private listeners = new Set<(info: HuaqiuUserInfo | null) => void>()
|
|
105
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
|
|
106
155
|
|
|
107
156
|
constructor(
|
|
108
157
|
config?: Partial<HuaqiuAuthConfig> | null,
|
|
@@ -115,20 +164,37 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
|
|
|
115
164
|
(resolved.hostSessionTtlSeconds ?? 300) * 1000,
|
|
116
165
|
opts?.fetchImpl,
|
|
117
166
|
)
|
|
167
|
+
this.validator = new TokenValidator({
|
|
168
|
+
ttlMs: (resolved.validationTtlSeconds ?? 60) * 1000,
|
|
169
|
+
fetchImpl: opts?.fetchImpl,
|
|
170
|
+
})
|
|
171
|
+
this.hostMode = this.host.enabled
|
|
118
172
|
}
|
|
119
173
|
|
|
174
|
+
/** Host mode is active iff a host base URL was configured (see HostSessionResolver.enabled). */
|
|
175
|
+
readonly hostMode: boolean
|
|
176
|
+
|
|
120
177
|
readonly auth: HuaqiuAuthApi = {
|
|
121
|
-
isAuthenticated: () =>
|
|
178
|
+
isAuthenticated: async () => {
|
|
179
|
+
if (this.stale) return false
|
|
180
|
+
return (await this.validateInternal()).status === 'valid'
|
|
181
|
+
},
|
|
122
182
|
getAccessToken: async () => (await this.resolve())?.token ?? null,
|
|
123
183
|
getUserInfo: async () => this.resolve(),
|
|
124
184
|
login: async () => {
|
|
125
185
|
/* login is a browser action */
|
|
126
186
|
},
|
|
127
187
|
logout: async () => this.invalidate(),
|
|
188
|
+
validate: () => this.validateInternal(),
|
|
189
|
+
invalidate: () => this.markStale(),
|
|
128
190
|
onAuthStateChanged: (listener) => this.on(listener),
|
|
129
191
|
}
|
|
130
192
|
|
|
131
|
-
/**
|
|
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
|
+
*/
|
|
132
198
|
private async resolve(): Promise<HuaqiuUserInfo | null> {
|
|
133
199
|
if (this.host.enabled) {
|
|
134
200
|
const host = await this.host.resolve()
|
|
@@ -138,8 +204,37 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
|
|
|
138
204
|
return readPersisted()
|
|
139
205
|
}
|
|
140
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
|
+
|
|
141
234
|
setCredentials(info: HuaqiuUserInfo): void {
|
|
142
235
|
this.current = info
|
|
236
|
+
this.stale = false
|
|
237
|
+
this.validator.invalidate()
|
|
143
238
|
void writePersisted(info)
|
|
144
239
|
this.emit()
|
|
145
240
|
}
|
|
@@ -148,6 +243,8 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
|
|
|
148
243
|
const was = this.host.enabled || this.current !== null || readPersisted() !== null
|
|
149
244
|
this.current = null
|
|
150
245
|
this.host.clear()
|
|
246
|
+
this.stale = true
|
|
247
|
+
this.validator.invalidate()
|
|
151
248
|
deletePersisted()
|
|
152
249
|
if (was) this.emit()
|
|
153
250
|
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token validation for `@huaqiu/dsh-auth`.
|
|
3
|
+
*
|
|
4
|
+
* Single authoritative validation path shared by standalone (auth.eda.cn) and
|
|
5
|
+
* HQ Edge host credentials. Uses the existing Huaqiu endpoint
|
|
6
|
+
*
|
|
7
|
+
* GET https://www.eda.cn/api/token/validate?token=<token>
|
|
8
|
+
* → { code, message, result: boolean }
|
|
9
|
+
*
|
|
10
|
+
* (the same endpoint consumed by `NextChat/app/auth/is_token_valid.ts`; probe:
|
|
11
|
+
* `curl "https://www.eda.cn/api/token/validate?token=__dummy__"` → 200
|
|
12
|
+
* `{"code":200000,"message":"success","result":false}`).
|
|
13
|
+
*
|
|
14
|
+
* Local expiry is a cheap pre-check only (never authoritative). Remote
|
|
15
|
+
* validation is authoritative, short-lived in-memory cached, and never
|
|
16
|
+
* persisted. Network / 5xx failures are reported as `unavailable` — they are
|
|
17
|
+
* NOT converted into "token invalid", so a transient network blip never forces
|
|
18
|
+
* the user to log in again.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Outcome of an authoritative token validation.
|
|
22
|
+
*
|
|
23
|
+
* - `valid` — the Huaqiu API accepted the token.
|
|
24
|
+
* - `invalid` — the token is definitively rejected/expired.
|
|
25
|
+
* - `unavailable` — validation could not be performed (network/5xx); the token
|
|
26
|
+
* is NOT declared invalid (spec §17/§18).
|
|
27
|
+
*/
|
|
28
|
+
export type AuthValidationResult =
|
|
29
|
+
| { status: 'valid'; userId?: string; expiresAt?: number }
|
|
30
|
+
| { status: 'invalid'; reason: 'expired' | 'unauthorized' | 'forbidden' | 'invalid' }
|
|
31
|
+
| { status: 'unavailable'; error: Error }
|
|
32
|
+
|
|
33
|
+
/** Default remote validation TTL — spec §8 (30–60s); 60s chosen. */
|
|
34
|
+
export const DEFAULT_VALIDATION_TTL_MS = 60_000
|
|
35
|
+
/** Existing Huaqiu token-validation endpoint (see header). */
|
|
36
|
+
export const DEFAULT_VALIDATE_URL = 'https://www.eda.cn/api/token/validate'
|
|
37
|
+
|
|
38
|
+
export interface TokenValidatorOptions {
|
|
39
|
+
/** Remote-validation cache TTL in ms. Default `DEFAULT_VALIDATION_TTL_MS`. */
|
|
40
|
+
ttlMs?: number
|
|
41
|
+
/** Validation endpoint. Default `DEFAULT_VALIDATE_URL`. */
|
|
42
|
+
validateUrl?: string
|
|
43
|
+
/** Injectable fetch (tests). Defaults to global fetch. */
|
|
44
|
+
fetchImpl?: typeof fetch
|
|
45
|
+
/** Injectable clock (tests). Defaults to Date.now. */
|
|
46
|
+
now?: () => number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface ValidationCacheEntry {
|
|
50
|
+
result: AuthValidationResult
|
|
51
|
+
at: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Classifies the HTTP status of the validation request.
|
|
56
|
+
*
|
|
57
|
+
* 401 → unauthorized; 403 → forbidden; any other non-ok status (incl. 5xx) →
|
|
58
|
+
* unavailable. A 2xx body of `{ result: false }` → unauthorized (the endpoint
|
|
59
|
+
* does not distinguish expired vs revoked, so we use the generic
|
|
60
|
+
* `unauthorized` reason; local expiry is the only source of `expired`).
|
|
61
|
+
*/
|
|
62
|
+
function classifyStatus(status: number): AuthValidationResult {
|
|
63
|
+
if (status === 401) return { status: 'invalid', reason: 'unauthorized' }
|
|
64
|
+
if (status === 403) return { status: 'invalid', reason: 'forbidden' }
|
|
65
|
+
return { status: 'unavailable', error: new Error(`token validation HTTP ${status}`) }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class TokenValidator {
|
|
69
|
+
private readonly ttlMs: number
|
|
70
|
+
private readonly validateUrl: string
|
|
71
|
+
private readonly fetchImpl: typeof fetch
|
|
72
|
+
private readonly now: () => number
|
|
73
|
+
/** token → { result, at }; in-memory only, never persisted (spec §8). */
|
|
74
|
+
private readonly cache = new Map<string, ValidationCacheEntry>()
|
|
75
|
+
/** token → in-flight promise; coalesces concurrent validate() calls (§16). */
|
|
76
|
+
private readonly inFlight = new Map<string, Promise<AuthValidationResult>>()
|
|
77
|
+
|
|
78
|
+
constructor(options: TokenValidatorOptions = {}) {
|
|
79
|
+
this.ttlMs = options.ttlMs ?? DEFAULT_VALIDATION_TTL_MS
|
|
80
|
+
this.validateUrl = options.validateUrl ?? DEFAULT_VALIDATE_URL
|
|
81
|
+
this.fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis)
|
|
82
|
+
this.now = options.now ?? (() => Date.now())
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Cheap local expiry check — an optimization, not authoritative validation.
|
|
87
|
+
* Unknown expiry ⇒ not assumed invalid (spec §6).
|
|
88
|
+
*/
|
|
89
|
+
isLocallyExpired(expiresAt?: number): boolean {
|
|
90
|
+
if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) return false
|
|
91
|
+
return expiresAt * 1000 <= this.now()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Validate a token. Cached within TTL; a single in-flight request is shared
|
|
96
|
+
* by concurrent callers. Never treats a network failure as "invalid".
|
|
97
|
+
*/
|
|
98
|
+
async validate(token: string, session?: { expiresAt?: number }): Promise<AuthValidationResult> {
|
|
99
|
+
if (!token) return { status: 'invalid', reason: 'invalid' }
|
|
100
|
+
|
|
101
|
+
// Local expiry first — no remote call (spec §6/§7).
|
|
102
|
+
if (this.isLocallyExpired(session?.expiresAt)) {
|
|
103
|
+
this.cache.delete(token)
|
|
104
|
+
return { status: 'invalid', reason: 'expired' }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Fresh cache hit within TTL.
|
|
108
|
+
const cached = this.cache.get(token)
|
|
109
|
+
if (cached && this.now() - cached.at < this.ttlMs) {
|
|
110
|
+
return cached.result
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Reuse an in-flight request instead of stacking duplicates (§16).
|
|
114
|
+
const inFlight = this.inFlight.get(token)
|
|
115
|
+
if (inFlight) return inFlight
|
|
116
|
+
|
|
117
|
+
const promise = this.validateRemotely(token)
|
|
118
|
+
this.inFlight.set(token, promise)
|
|
119
|
+
try {
|
|
120
|
+
const result = await promise
|
|
121
|
+
this.cache.set(token, { result, at: this.now() })
|
|
122
|
+
return result
|
|
123
|
+
} finally {
|
|
124
|
+
this.inFlight.delete(token)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Drop cached/in-flight validation for a token (or all when omitted). */
|
|
129
|
+
invalidate(token?: string): void {
|
|
130
|
+
if (token === undefined) {
|
|
131
|
+
this.cache.clear()
|
|
132
|
+
this.inFlight.clear()
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
this.cache.delete(token)
|
|
136
|
+
this.inFlight.delete(token)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Resolve a currently-cached result (no remote call), or null. */
|
|
140
|
+
peek(token: string): AuthValidationResult | null {
|
|
141
|
+
const cached = this.cache.get(token)
|
|
142
|
+
if (!cached) return null
|
|
143
|
+
if (this.now() - cached.at >= this.ttlMs) {
|
|
144
|
+
this.cache.delete(token)
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
return cached.result
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private async validateRemotely(token: string): Promise<AuthValidationResult> {
|
|
151
|
+
const url = `${this.validateUrl}?token=${encodeURIComponent(token)}`
|
|
152
|
+
let res: Response
|
|
153
|
+
try {
|
|
154
|
+
res = await this.fetchImpl(url, {
|
|
155
|
+
method: 'GET',
|
|
156
|
+
headers: { accept: 'application/json' },
|
|
157
|
+
})
|
|
158
|
+
} catch (err) {
|
|
159
|
+
// Network failure — NOT "token invalid" (spec §17). Do not force a login
|
|
160
|
+
// merely because the network is temporarily unavailable.
|
|
161
|
+
return { status: 'unavailable', error: err as Error }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!res.ok) return classifyStatus(res.status)
|
|
165
|
+
|
|
166
|
+
let body: unknown
|
|
167
|
+
try {
|
|
168
|
+
body = await res.json()
|
|
169
|
+
} catch (err) {
|
|
170
|
+
return { status: 'unavailable', error: new Error(`token validation: unparseable response (${String(err)})`) }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const result = typeof body === 'object' && body !== null
|
|
174
|
+
? (body as Record<string, unknown>).result
|
|
175
|
+
: undefined
|
|
176
|
+
if (result === true) {
|
|
177
|
+
return { status: 'valid' }
|
|
178
|
+
}
|
|
179
|
+
// 2xx but the token is not accepted (`result` missing/false).
|
|
180
|
+
return { status: 'invalid', reason: 'unauthorized' }
|
|
181
|
+
}
|
|
182
|
+
}
|