@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.
@@ -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
+ }