@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/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
- isAuthenticated(): boolean
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
- /** Node-only setters used by the webServer route handlers. */
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
- return { id, token, ...(nickname ? { nickname } : {}) }
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: () => this.host.enabled || this.current !== null || readPersisted() !== null,
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
- /** Spec §6.2 resolution order: host → pushed → persisted → null. */
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
+ }