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