@eduwork/dsh-oidc 0.2.4 → 0.3.0-dev.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.
Files changed (67) hide show
  1. package/README.md +20 -132
  2. package/README_EN.md +20 -177
  3. package/docs/EDUWORK-MIGRATION.en.md +3 -25
  4. package/docs/EDUWORK-MIGRATION.md +3 -25
  5. package/docs/account-extensions.en.md +6 -6
  6. package/docs/account-extensions.md +3 -3
  7. package/docs/architecture.en.md +12 -117
  8. package/docs/architecture.md +12 -118
  9. package/docs/compatibility.en.md +6 -40
  10. package/docs/compatibility.md +6 -40
  11. package/docs/decisions/0001-one-plugin-composition-boundary.en.md +2 -2
  12. package/docs/decisions/0001-one-plugin-composition-boundary.md +2 -2
  13. package/docs/desktop-host.en.md +4 -5
  14. package/docs/desktop-host.md +8 -9
  15. package/docs/development.en.md +2 -2
  16. package/docs/development.md +2 -2
  17. package/docs/dsh-integration.en.md +11 -46
  18. package/docs/dsh-integration.md +12 -47
  19. package/docs/ecnu-reference.en.md +4 -61
  20. package/docs/ecnu-reference.md +4 -61
  21. package/docs/enterprise-profile.en.md +10 -31
  22. package/docs/enterprise-profile.md +11 -32
  23. package/docs/gateway-auth/README.md +168 -0
  24. package/docs/gateway-auth/README_EN.md +70 -0
  25. package/docs/gateway-auth/experimental-oidc-llm.en.md +53 -0
  26. package/docs/gateway-auth/experimental-oidc-llm.md +53 -0
  27. package/docs/gateway-auth/litellm-setup.en.md +114 -0
  28. package/docs/gateway-auth/litellm-setup.md +114 -0
  29. package/docs/gateway-auth/oidc-llm-draft.en.md +141 -0
  30. package/docs/gateway-auth/oidc-llm-draft.md +141 -0
  31. package/docs/getting-started.en.md +17 -122
  32. package/docs/getting-started.md +16 -225
  33. package/docs/key-binding-protocol.en.md +10 -220
  34. package/docs/key-binding-protocol.md +10 -220
  35. package/docs/oidc-interoperability.en.md +1 -1
  36. package/docs/oidc-interoperability.md +1 -1
  37. package/docs/open-integration.en.md +5 -67
  38. package/docs/open-integration.md +5 -67
  39. package/docs/public-resource-protocol.en.md +8 -44
  40. package/docs/public-resource-protocol.md +8 -43
  41. package/docs/security-model.en.md +10 -132
  42. package/docs/security-model.md +10 -132
  43. package/docs/server-integration-contract.en.md +12 -813
  44. package/docs/server-integration-contract.md +12 -825
  45. package/examples/ecnu.enterprise-profile.example.json +14 -19
  46. package/examples/enterprise-profile.example.json +14 -19
  47. package/examples/litellm.enterprise-profile.example.json +9 -0
  48. package/examples/oidc-llm.enterprise-profile.example.json +12 -0
  49. package/examples/resources-discovery.example.json +5 -8
  50. package/lib/callback-page.js +5 -5
  51. package/lib/client.js +31 -48
  52. package/lib/desktop-oidc.js +7 -11
  53. package/lib/gateway-backend.js +443 -0
  54. package/lib/gateway-protocol.js +6 -0
  55. package/lib/index.js +14 -15
  56. package/lib/litellm-protocol.js +117 -0
  57. package/lib/model-resource-transport.js +19 -0
  58. package/lib/oidc-llm-protocol.js +88 -0
  59. package/lib/oidc.js +37 -323
  60. package/lib/profile.js +34 -40
  61. package/lib/provider/authorization-scope.js +46 -0
  62. package/lib/provider/index.js +11 -2
  63. package/lib/typert-schemas.js +1 -1
  64. package/package.json +2 -2
  65. package/protocol/openapi.yaml +33 -240
  66. package/protocol/resources.openapi.yaml +8 -9
  67. package/schema/enterprise-profile.v1alpha1.schema.json +509 -107
@@ -0,0 +1,443 @@
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto'
2
+ import { WebOidcBackend, account, sessionRef } from './oidc.js'
3
+ import { DesktopOidcBackend } from './desktop-oidc.js'
4
+ import { emptyResources, normalizeResourceModels } from './resources.js'
5
+ import { gatewayJSON, liteLLMToken, protocolError, readGatewayJSON, registerLiteLLM, revokeLiteLLM, tokenRequest } from './litellm-protocol.js'
6
+ import { detectGatewayProtocol } from './gateway-protocol.js'
7
+ import { oidcLlmIdentity, oidcLlmToken } from './oidc-llm-protocol.js'
8
+ import { bufferResourceResponse, validateResourceRead } from './model-resource-transport.js'
9
+
10
+ const seconds = backend => Math.floor(backend.now() / 1000)
11
+ const fingerprint = (profile, descriptor) => createHash('sha256').update(JSON.stringify({ auth: profile.auth, descriptor })).digest('hex')
12
+ const query = (url, key, required = false) => {
13
+ const values = url.searchParams.getAll(key)
14
+ if (values.length > 1 || (required && !values[0])) throw protocolError('oidc_callback_invalid', 'Invalid gateway callback parameters')
15
+ return values[0]
16
+ }
17
+
18
+ function scopedResponse(response, lease) {
19
+ if (!response.body) { lease.close(); return response }
20
+ const reader = response.body.getReader()
21
+ const body = new ReadableStream({
22
+ async pull(controller) {
23
+ try {
24
+ lease.assertCurrent()
25
+ const next = await reader.read()
26
+ lease.assertCurrent()
27
+ if (next.done) { lease.close(); controller.close() }
28
+ else controller.enqueue(next.value)
29
+ } catch (cause) {
30
+ lease.close()
31
+ await reader.cancel().catch(() => {})
32
+ controller.error(cause)
33
+ }
34
+ },
35
+ async cancel(reason) { lease.close(); await reader.cancel(reason).catch(() => {}) },
36
+ }, { highWaterMark: 0 })
37
+ return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers })
38
+ }
39
+
40
+ /** Token model protocols share the OIDC identity and callback implementation. */
41
+ function withGatewayAuth(Base) {
42
+ return class extends Base {
43
+ constructor(...args) {
44
+ super(...args)
45
+ this.gatewayRevisions = new Map()
46
+ this.gatewayCalls = new Map()
47
+ this.ctx.effect(() => () => {
48
+ for (const profile of this.profiles.values()) if (profile.auth) this.invalidateGatewayCalls(profile)
49
+ }, 'dsh-oidc: gateway model requests')
50
+ }
51
+
52
+ invalidateGatewayCalls(profile) {
53
+ this.gatewayRevisions.set(profile.id, (this.gatewayRevisions.get(profile.id) ?? 0) + 1)
54
+ for (const controller of this.gatewayCalls.get(profile.id) ?? []) controller.abort()
55
+ this.gatewayCalls.delete(profile.id)
56
+ }
57
+
58
+ async createGatewayScope(profileID) {
59
+ const profile = this.profile(profileID), epoch = this.accountEpoch(profile)
60
+ const revision = this.gatewayRevisions.get(profileID) ?? 0
61
+ const session = await this.activeSession(profile)
62
+ if (!session) throw protocolError('oidc_login_required', 'Gateway sign-in is required')
63
+ await this.gatewayCurrent(profile, session, epoch)
64
+ const assertCurrent = () => {
65
+ if (epoch !== this.accountEpoch(profile) || revision !== (this.gatewayRevisions.get(profileID) ?? 0)) {
66
+ throw protocolError('oidc_login_cancelled', 'Gateway authorization changed; start a new model request')
67
+ }
68
+ }
69
+ assertCurrent()
70
+ return {
71
+ open: signal => {
72
+ assertCurrent()
73
+ const controller = new AbortController()
74
+ let calls = this.gatewayCalls.get(profileID)
75
+ if (!calls) this.gatewayCalls.set(profileID, calls = new Set())
76
+ calls.add(controller)
77
+ return {
78
+ signal: signal ? AbortSignal.any([signal, controller.signal]) : controller.signal,
79
+ assertCurrent,
80
+ resolveCredential: async providerID => {
81
+ assertCurrent()
82
+ if (providerID !== profile.provider.id) throw protocolError('oidc_authorized_origin_denied', 'Gateway credential does not belong to this provider')
83
+ const current = await this.activeSession(profile)
84
+ assertCurrent()
85
+ if (!current || current.contextID !== session.contextID) throw protocolError('oidc_login_cancelled', 'Gateway account changed during the request')
86
+ return { value: current.accessToken }
87
+ },
88
+ close: () => { controller.abort(); calls.delete(controller); if (!calls.size && this.gatewayCalls.get(profileID) === calls) this.gatewayCalls.delete(profileID) },
89
+ }
90
+ },
91
+ }
92
+ }
93
+
94
+ async discover(profile) {
95
+ if (!profile.auth) return super.discover(profile)
96
+ const cached = this.discovery.get(profile.id)
97
+ if (cached) return cached
98
+ const raw = await gatewayJSON(this.fetch, profile.auth.discoveryUrl, { headers: { accept: 'application/json' } })
99
+ const descriptor = detectGatewayProtocol(raw, profile)
100
+ this.discovery.set(profile.id, descriptor)
101
+ return descriptor
102
+ }
103
+
104
+ async createAuthorization(profile, descriptor, redirectURI) {
105
+ if (!profile.auth) return super.createAuthorization(profile, descriptor, redirectURI)
106
+ const epoch = this.accountEpoch(profile)
107
+ const clientId = descriptor.clientId ?? await registerLiteLLM(this.fetch, descriptor, redirectURI)
108
+ if (epoch !== this.accountEpoch(profile)) throw protocolError('oidc_login_cancelled', 'Sign-in was cancelled')
109
+ this.pruneFlows()
110
+ if (this.flows.size >= 32) throw protocolError('oidc_flow_limit', 'Too many pending sign-ins')
111
+ const state = randomBytes(32).toString('base64url'), verifier = randomBytes(48).toString('base64url')
112
+ const flow = { profileID: profile.id, verifier, clientId, redirectURI, createdAt: this.now(), epoch }
113
+ const target = new URL(descriptor.authorizationEndpoint)
114
+ for (const [key, value] of Object.entries({ response_type: 'code', client_id: clientId, redirect_uri: redirectURI, state, code_challenge: createHash('sha256').update(verifier).digest('base64url'), code_challenge_method: 'S256', resource: descriptor.resource })) target.searchParams.set(key, value)
115
+ if (descriptor.scopes) target.searchParams.set('scope', descriptor.scopes.join(' '))
116
+ if (descriptor.identityMode === 'oidc') {
117
+ flow.nonce = randomBytes(32).toString('base64url')
118
+ target.searchParams.set('nonce', flow.nonce)
119
+ target.searchParams.set('prompt', 'consent')
120
+ }
121
+ this.flows.set(state, flow)
122
+ return { state, flow, authorizationURL: target.toString() }
123
+ }
124
+
125
+ async exchangeAuthorization(requested, flow) {
126
+ const profile = this.profile(flow.profileID)
127
+ if (!profile.auth) return super.exchangeAuthorization(requested, flow)
128
+ const descriptor = await this.discover(profile)
129
+ const issuers = requested.searchParams.getAll('iss')
130
+ if (!issuers.length && descriptor.requireResponseIssuer) throw protocolError('gateway_callback_issuer_missing', 'Gateway authorization response omitted its required issuer')
131
+ if (issuers.length > 1 || issuers.length === 1 && issuers[0] !== descriptor.issuer) throw protocolError('gateway_callback_issuer_invalid', 'Gateway authorization response issuer is invalid')
132
+ if (query(requested, 'error')) throw protocolError('oidc_authorization_rejected', 'Gateway authorization was declined')
133
+ const code = query(requested, 'code', true)
134
+ const raw = await tokenRequest(this.fetch, descriptor, { grant_type: 'authorization_code', client_id: flow.clientId, code, redirect_uri: flow.redirectURI, code_verifier: flow.verifier })
135
+ const draft = descriptor.protocol === 'oidc-llm-draft-0.1'
136
+ let token
137
+ try { token = draft ? oidcLlmToken(raw, descriptor, this.now) : liteLLMToken(raw, this.now) }
138
+ catch (cause) {
139
+ if (draft && typeof raw.refresh_token === 'string' && raw.refresh_token.length > 0 && raw.refresh_token.length <= 65536) await this.revokeGateway(profile, descriptor, { clientId: flow.clientId, refreshToken: raw.refresh_token })
140
+ throw cause
141
+ }
142
+ const session = { ...token, issuer: descriptor.issuer, protocol: descriptor.protocol, clientId: flow.clientId, binding: fingerprint(profile, descriptor), contextID: randomUUID(), capabilities: [] }
143
+ if (draft) {
144
+ session.identityMode = descriptor.identityMode
145
+ session.oidcNonce = flow.nonce
146
+ try { await this.gatewayIdentity(profile, descriptor, raw, session) }
147
+ catch (cause) { await this.revokeGateway(profile, descriptor, session); throw cause }
148
+ } else {
149
+ // Only project a display name; budgets, keys and team records never enter RPC.
150
+ try {
151
+ const info = await gatewayJSON(this.fetch, descriptor.userInfoEndpoint, { headers: { authorization: `Bearer ${session.accessToken}`, accept: 'application/json' } })
152
+ if (info.user_id === session.identity.sub && typeof info.user_info?.user_alias === 'string' && info.user_info.user_alias.trim()) session.identity.name = info.user_info.user_alias.trim().slice(0, 256)
153
+ } catch { /* Identity is supplied by the token endpoint; display data is optional. */ }
154
+ }
155
+ if (flow.epoch !== this.accountEpoch(profile)
156
+ || this.attempts && ![...this.attempts.values()].some(attempt => attempt.flow === flow && attempt.state === 'pending')) {
157
+ await this.revokeGateway(profile, descriptor, session)
158
+ throw protocolError('oidc_login_cancelled', 'Account changed during sign-in')
159
+ }
160
+ return session
161
+ }
162
+
163
+ async gatewayIdentity(profile, descriptor, raw, session, previous) {
164
+ let claims
165
+ if (descriptor.identityMode === 'oidc' && (!previous || raw.id_token !== undefined)) {
166
+ claims = await this.verifyIDToken({ oidc: { issuer: descriptor.issuer, clientId: session.clientId } }, descriptor,
167
+ raw.id_token, session.oidcNonce, session.accessToken, { refresh: Boolean(previous) })
168
+ const audience = (Array.isArray(claims.aud) ? [...claims.aud] : [claims.aud]).sort()
169
+ if (previous && (claims.sub !== previous.identity.sub || JSON.stringify(audience) !== JSON.stringify(previous.oidcAudience)
170
+ || claims.auth_time !== undefined && claims.auth_time !== previous.authTime)) {
171
+ throw protocolError('gateway_identity_changed', 'OIDC refresh identity changed')
172
+ }
173
+ session.oidcAudience = audience
174
+ if (claims.auth_time !== undefined) session.authTime = claims.auth_time
175
+ }
176
+ const info = await gatewayJSON(this.fetch, descriptor.userInfoEndpoint, { headers: { authorization: `Bearer ${session.accessToken}`, accept: 'application/json' } })
177
+ session.identity = oidcLlmIdentity(info)
178
+ if (claims && claims.sub !== session.identity.sub || previous && previous.identity.sub !== session.identity.sub) throw protocolError('gateway_identity_changed', 'UserInfo subject does not match the authorization')
179
+ }
180
+
181
+ async loadSession(profile) {
182
+ if (!profile.auth) return super.loadSession(profile)
183
+ const epoch = this.accountEpoch(profile)
184
+ const record = await this.ctx.credentials.resolve(sessionRef(profile))
185
+ if (!record?.value) return undefined
186
+ let session
187
+ try { session = JSON.parse(record.value) } catch { return undefined }
188
+ const descriptor = await this.discover(profile)
189
+ if (epoch !== this.accountEpoch(profile)) return undefined
190
+ if (session?.protocol !== descriptor.protocol || session.binding !== fingerprint(profile, descriptor) || typeof session.contextID !== 'string' || !session.contextID || typeof session.accessToken !== 'string' || !session.accessToken || typeof session.refreshToken !== 'string' || !session.refreshToken || typeof session.clientId !== 'string' || !session.clientId || !Number.isFinite(session.expiresAt) || typeof session.identity?.sub !== 'string' || !session.identity.sub || !(session.teamID === null || typeof session.teamID === 'string')) return undefined
191
+ if (descriptor.protocol === 'oidc-llm-draft-0.1' && (session.clientId !== descriptor.clientId || session.identityMode !== descriptor.identityMode
192
+ || !Array.isArray(session.scopes) || descriptor.scopes.some(scope => !session.scopes.includes(scope))
193
+ || session.scopes.some(scope => !descriptor.scopes.includes(scope))
194
+ || descriptor.identityMode === 'oidc' && typeof session.oidcNonce !== 'string')) return undefined
195
+ return session
196
+ }
197
+
198
+ async saveSession(profile, session, epoch = this.accountEpoch(profile)) {
199
+ if (!profile.auth) return super.saveSession(profile, session, epoch)
200
+ return this.writeCredentials(profile, epoch, async () => {
201
+ await this.ctx.credentials.set(sessionRef(profile), JSON.stringify(session))
202
+ this.invalidateGatewayCalls(profile)
203
+ this.resourceFlights.delete(profile.id)
204
+ await this.clearGatewayModels(profile)
205
+ })
206
+ }
207
+
208
+ refresh(profile, session) {
209
+ if (!profile.auth) return super.refresh(profile, session)
210
+ const key = `${profile.id}:${session.contextID}`
211
+ if (!this.refreshFlights.has(key)) {
212
+ const flight = this.performRefresh(profile, session).finally(() => { if (this.refreshFlights.get(key) === flight) this.refreshFlights.delete(key) })
213
+ this.refreshFlights.set(key, flight)
214
+ }
215
+ return this.refreshFlights.get(key)
216
+ }
217
+
218
+ async gatewayCurrent(profile, session, epoch) {
219
+ const current = await this.loadSession(profile)
220
+ if (epoch !== this.accountEpoch(profile) || current?.contextID !== session.contextID) throw protocolError('oidc_login_cancelled', 'Gateway account changed during the request')
221
+ return current
222
+ }
223
+
224
+ async performRefresh(profile, session) {
225
+ if (!profile.auth) return super.performRefresh(profile, session)
226
+ const epoch = this.accountEpoch(profile), descriptor = await this.discover(profile)
227
+ const saved = await this.gatewayCurrent(profile, session, epoch)
228
+ // A request may have captured the old pair before the previous flight settled.
229
+ if (saved.accessToken !== session.accessToken) return saved
230
+ let next, received
231
+ try {
232
+ const raw = await tokenRequest(this.fetch, descriptor, { grant_type: 'refresh_token', client_id: saved.clientId, refresh_token: saved.refreshToken })
233
+ received = raw
234
+ if (descriptor.protocol === 'oidc-llm-draft-0.1') {
235
+ next = { ...saved, ...oidcLlmToken(raw, descriptor, this.now, saved) }
236
+ await this.gatewayIdentity(profile, descriptor, raw, next, saved)
237
+ } else next = { ...saved, ...liteLLMToken(raw, this.now) }
238
+ if (next.identity.sub !== saved.identity.sub || next.teamID !== saved.teamID) throw protocolError('gateway_identity_changed', 'Gateway authorization identity changed during refresh')
239
+ next.identity = saved.identity
240
+ await this.writeCredentials(profile, epoch, async () => {
241
+ // Serialize persistence of the entire rotated pair. Never overwrite a new login.
242
+ await this.gatewayCurrent(profile, saved, epoch)
243
+ await this.ctx.credentials.set(sessionRef(profile), JSON.stringify(next))
244
+ })
245
+ return next
246
+ } catch (cause) {
247
+ if (next) await this.revokeGateway(profile, descriptor, next)
248
+ else if (descriptor.protocol === 'oidc-llm-draft-0.1' && typeof received?.refresh_token === 'string') await this.revokeGateway(profile, descriptor, { ...saved, refreshToken: received.refresh_token })
249
+ if (cause.oauthError === 'invalid_grant' || cause.oauthError === 'invalid_client' || cause.code === 'gateway_identity_changed'
250
+ || descriptor.protocol === 'oidc-llm-draft-0.1' && received) {
251
+ await this.writeCredentials(profile, epoch, async () => {
252
+ await this.gatewayCurrent(profile, saved, epoch)
253
+ await this.ctx.credentials.unset(sessionRef(profile))
254
+ this.invalidateGatewayCalls(profile)
255
+ })
256
+ await this.clearGatewayModels(profile)
257
+ this.accountChanged(account(profile, undefined, false))
258
+ throw protocolError('oidc_login_required', 'Gateway authorization expired; please sign in again')
259
+ }
260
+ throw cause
261
+ }
262
+ }
263
+
264
+ async activeSession(profile) {
265
+ if (!profile.auth) return super.activeSession(profile)
266
+ const epoch = this.accountEpoch(profile)
267
+ const session = await this.loadSession(profile)
268
+ if (!session) return undefined
269
+ if (session.expiresAt > seconds(this) + 90) return session
270
+ try { return await this.refresh(profile, session) }
271
+ catch (cause) {
272
+ // Brief outages must not discard a still-valid access token; expired tokens stop.
273
+ if ((cause.code === 'gateway_unavailable' || cause.status === 429 || cause.status >= 500) && session.expiresAt > seconds(this)) return this.gatewayCurrent(profile, session, epoch)
274
+ throw cause
275
+ }
276
+ }
277
+
278
+ async modelAuthorization(profileID, expectedBaseURL) {
279
+ const profile = this.profile(profileID), epoch = this.accountEpoch(profile)
280
+ if (!profile.auth || typeof expectedBaseURL !== 'string') return false
281
+ const descriptor = await this.discover(profile)
282
+ if (expectedBaseURL !== descriptor.baseURL) return false
283
+ const session = await this.activeSession(profile)
284
+ if (!session) return false
285
+ await this.gatewayCurrent(profile, session, epoch)
286
+ return true
287
+ }
288
+
289
+ async resolveGatewayCredential(profileID) {
290
+ const profile = this.profile(profileID), epoch = this.accountEpoch(profile)
291
+ const session = await this.activeSession(profile)
292
+ if (!session) return undefined
293
+ await this.gatewayCurrent(profile, session, epoch)
294
+ return { value: session.accessToken }
295
+ }
296
+
297
+ async status(profileID) {
298
+ const profile = this.profile(profileID)
299
+ if (!profile.auth) return super.status(profileID)
300
+ const epoch = this.accountEpoch(profile), session = await this.loadSession(profile)
301
+ if (!session) return account(profile, undefined, false)
302
+ if (!this.resourceStates.has(profileID)) await this.resources(profileID)
303
+ await this.gatewayCurrent(profile, session, epoch)
304
+ return account(profile, session, session.expiresAt > seconds(this) || Boolean(session.refreshToken), 'access_token')
305
+ }
306
+
307
+ async reconcile(profileID, options = {}) {
308
+ const profile = this.profile(profileID)
309
+ if (!profile.auth) return super.reconcile(profileID, options)
310
+ const epoch = this.accountEpoch(profile), session = await this.activeSession(profile)
311
+ if (!session) return account(profile, undefined, false)
312
+ await this.resources(profileID)
313
+ await this.gatewayCurrent(profile, session, epoch)
314
+ return account(profile, session, true, 'access_token')
315
+ }
316
+
317
+ async authorizedFetch(profileID, endpoint, init = {}) {
318
+ const profile = this.profile(profileID)
319
+ if (!profile.auth) return super.authorizedFetch(profileID, endpoint, init)
320
+ const epoch = this.accountEpoch(profile), descriptor = await this.discover(profile)
321
+ const target = new URL(endpoint)
322
+ // Host-only model and own-account routes, never key administration or arbitrary origins.
323
+ const modelBase = new URL(`${descriptor.baseURL}/`)
324
+ if (target.username || target.password || target.hash || target.origin !== modelBase.origin || !(target.href === descriptor.userInfoEndpoint || target.pathname.startsWith(modelBase.pathname))) throw protocolError('oidc_authorized_origin_denied', 'Gateway token destination is not an authorized resource')
325
+ const scope = await this.createGatewayScope(profileID)
326
+ const lease = scope.open(init.signal ?? AbortSignal.timeout(20_000))
327
+ let response
328
+ try {
329
+ let session = await this.activeSession(profile)
330
+ lease.assertCurrent()
331
+ if (!session) throw protocolError('oidc_login_required', 'Gateway sign-in is required')
332
+ const request = current => this.fetch(target.href, { ...init, headers: { ...Object.fromEntries(new Headers(init.headers)), authorization: `Bearer ${current.accessToken}` }, redirect: 'error', signal: lease.signal })
333
+ response = await request(session)
334
+ // Retry only safe reads. A generation (including SSE) is never replayed here.
335
+ if (response.status === 401 && ['GET', 'HEAD'].includes((init.method ?? 'GET').toUpperCase())) {
336
+ await response.body?.cancel()
337
+ session = await this.refresh(profile, session)
338
+ lease.assertCurrent()
339
+ response = await request(session)
340
+ }
341
+ await this.gatewayCurrent(profile, session, epoch)
342
+ lease.assertCurrent()
343
+ return scopedResponse(response, lease)
344
+ } catch (cause) {
345
+ lease.close()
346
+ await response?.body?.cancel().catch(() => {})
347
+ throw cause
348
+ }
349
+ }
350
+
351
+ async modelResourceFetch(profileID, relativePath, options = {}) {
352
+ const profile = this.profile(profileID)
353
+ if (!profile.auth) return super.modelResourceFetch(profileID, relativePath, options)
354
+ validateResourceRead(relativePath, options)
355
+ const epoch = this.accountEpoch(profile), revision = this.gatewayRevisions.get(profileID) ?? 0
356
+ const descriptor = await this.discover(profile)
357
+ const response = await this.authorizedFetch(profileID, `${descriptor.baseURL}${relativePath}`, {
358
+ method: 'GET', headers: { accept: 'application/json' },
359
+ signal: AbortSignal.any([AbortSignal.timeout(20_000), ...(options.signal ? [options.signal] : [])]),
360
+ })
361
+ return bufferResourceResponse(response, () => {
362
+ if (epoch !== this.accountEpoch(profile) || revision !== (this.gatewayRevisions.get(profileID) ?? 0)) throw protocolError('oidc_login_cancelled', 'Gateway account changed during resource request')
363
+ })
364
+ }
365
+
366
+ async readResources(profileID) {
367
+ const profile = this.profile(profileID)
368
+ if (!profile.auth) return super.readResources(profileID)
369
+ const result = { ...emptyResources(profile), models: [] }, epoch = this.accountEpoch(profile)
370
+ const session = await this.activeSession(profile)
371
+ if (!session) return result
372
+ const descriptor = await this.discover(profile)
373
+ try {
374
+ const raw = await readGatewayJSON(await this.authorizedFetch(profileID, `${descriptor.baseURL}/models`))
375
+ const models = Array.isArray(raw.data) && raw.data.length === 0 ? [] : normalizeResourceModels(raw, this.reviewedProfiles.get(profileID))
376
+ await this.gatewayCurrent(profile, session, epoch)
377
+ const next = Object.freeze({ ...profile, provider: Object.freeze({ ...profile.provider, baseURL: descriptor.baseURL, models }) })
378
+ // updateProvider is synchronous in the Host; no asynchronous account switch gap.
379
+ this.updateProvider(next)
380
+ this.profiles.set(profileID, next)
381
+ result.models = models
382
+ } catch (cause) {
383
+ if (cause.code === 'oidc_login_cancelled' || cause.code === 'oidc_login_required') throw cause
384
+ result.issues.push('models_unavailable')
385
+ }
386
+ await this.gatewayCurrent(profile, session, epoch)
387
+ this.resourceStates.set(profileID, result)
388
+ return result
389
+ }
390
+
391
+ async clearGatewayModels(profile) {
392
+ const next = Object.freeze({ ...profile, provider: Object.freeze({ ...profile.provider, models: [] }) })
393
+ this.updateProvider(next)
394
+ this.profiles.set(profile.id, next)
395
+ this.resourceStates.delete(profile.id)
396
+ }
397
+
398
+ async revokeGateway(profile, descriptor, session) {
399
+ try {
400
+ if (descriptor.protocol !== 'oidc-llm-draft-0.1') await revokeLiteLLM(this.fetch, descriptor, session)
401
+ else {
402
+ const response = await this.fetch(descriptor.revocationEndpoint, {
403
+ method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
404
+ body: new URLSearchParams({ token: session.refreshToken, token_type_hint: 'refresh_token', client_id: session.clientId }),
405
+ redirect: 'error', signal: AbortSignal.timeout(5_000),
406
+ })
407
+ await response.body?.cancel()
408
+ if (response.status !== 200) throw protocolError('gateway_revocation_failed', 'Remote revocation was not confirmed')
409
+ }
410
+ }
411
+ catch { this.ctx.logger.warn('Gateway refresh revocation was not confirmed; local logout still removes access') }
412
+ }
413
+
414
+ async logout(profileID) {
415
+ const profile = this.profile(profileID)
416
+ if (!profile.auth) return super.logout(profileID)
417
+ this.accountEpochs.set(profileID, this.accountEpoch(profile) + 1)
418
+ this.invalidateGatewayCalls(profile)
419
+ for (const attempt of this.attempts?.values() ?? []) if (attempt.profileID === profileID && attempt.state === 'pending') await this.cancelLogin(attempt.loginID)
420
+ const epoch = this.accountEpoch(profile)
421
+ // Clear locally even when discovery is offline on a fresh process.
422
+ const record = await this.ctx.credentials.resolve(sessionRef(profile))
423
+ let session
424
+ try { session = JSON.parse(record?.value) } catch { /* No valid revocation candidate. */ }
425
+ await this.clearCredentials(profile, epoch)
426
+ for (const key of this.refreshFlights.keys()) if (key.startsWith(`${profileID}:`)) this.refreshFlights.delete(key)
427
+ this.resourceFlights.delete(profileID)
428
+ for (const [state, flow] of this.flows) if (flow.profileID === profileID) this.flows.delete(state)
429
+ await this.clearGatewayModels(profile)
430
+ this.accountChanged(account(profile, undefined, false))
431
+ if (session) {
432
+ try {
433
+ const descriptor = await this.discover(profile)
434
+ if (session.binding === fingerprint(profile, descriptor)) await this.revokeGateway(profile, descriptor, session)
435
+ } catch { this.ctx.logger.warn('Gateway logout completed locally; remote revocation could not be confirmed') }
436
+ }
437
+ return account(profile, undefined, false)
438
+ }
439
+ }
440
+ }
441
+
442
+ export const GatewayWebBackend = withGatewayAuth(WebOidcBackend)
443
+ export const GatewayDesktopBackend = withGatewayAuth(DesktopOidcBackend)
@@ -0,0 +1,6 @@
1
+ import { detectGatewayProtocol as detectLiteLLM, protocolError } from './litellm-protocol.js'
2
+ import { detectOidcLlm } from './oidc-llm-protocol.js'
3
+ export function detectGatewayProtocol(raw, profile) {
4
+ if (profile.auth.experimentalOidcLlm && !Object.hasOwn(raw, 'oidc_llm')) throw protocolError('gateway_protocol_unsupported', 'Configured oidc-llm authorization cannot fall back to another gateway protocol')
5
+ return Object.hasOwn(raw, 'oidc_llm') ? detectOidcLlm(raw, profile) : detectLiteLLM(raw, profile)
6
+ }
package/lib/index.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
2
2
  import { apply as applyEnterpriseProvider } from './provider/index.js'
3
- import { NativeOidcBackend, WebOidcBackend } from './oidc.js'
4
- import { DesktopOidcBackend } from './desktop-oidc.js'
3
+ import { GatewayDesktopBackend, GatewayWebBackend } from './gateway-backend.js'
5
4
  import { enterpriseProviderConfig, loadEnterpriseProfiles, publicProfile } from './profile.js'
6
5
 
7
6
  export const name = 'dsh-oidc'
@@ -14,6 +13,7 @@ export class OidcAccountService extends TypertRemoteService {
14
13
  super(ctx, 'oidcAccounts')
15
14
  for (const initialize of remoteInitializers) initialize.call(this)
16
15
  this.profiles = loadEnterpriseProfiles(config)
16
+ if (config.backend === 'native') throw new Error('The legacy native account bridge has been removed; use the desktop or web Host backend')
17
17
  if (this.profiles.size === 0 && config.allowEmptyProfiles !== true) throw new Error('dsh-oidc requires at least one Enterprise Profile')
18
18
  this.uiMode = ['external', 'models-only'].includes(config.uiMode) ? config.uiMode : 'standard'
19
19
  this.manageProductBrand = config.manageProductBrand !== false
@@ -33,13 +33,14 @@ export class OidcAccountService extends TypertRemoteService {
33
33
  this.accountStates = new Map()
34
34
  const provider = applyEnterpriseProvider(ctx, {
35
35
  ...enterpriseProviderConfig(this.profiles),
36
+ createAuthorizationScope: providerID => {
37
+ const profile = [...this.profiles.values()].find(value => value.provider?.id === providerID)
38
+ return profile?.auth ? this.backend.createGatewayScope(profile.id) : undefined
39
+ },
36
40
  resolveCredential: async providerID => {
37
41
  const profile = [...this.profiles.values()].find(value => value.provider?.id === providerID)
38
42
  if (!profile) return undefined
39
- if (this.backend instanceof NativeOidcBackend) return ctx.credentials.resolve(profile.keyBinding.credentialRef)
40
- if (this.backend instanceof WebOidcBackend) return this.backend.resolveBoundCredential(profile.id, {
41
- credentialRef: profile.keyBinding.credentialRef, runtimeBaseURL: profile.provider.baseURL,
42
- })
43
+ return this.backend?.resolveGatewayCredential(profile.id)
43
44
  },
44
45
  })
45
46
  const backendOptions = {
@@ -51,9 +52,8 @@ export class OidcAccountService extends TypertRemoteService {
51
52
  provider.replaceConfig(enterpriseProviderConfig(profiles))
52
53
  },
53
54
  }
54
- if (config.backend === 'native') this.backend = new NativeOidcBackend(ctx, this.profiles)
55
- else if (config.backend === 'desktop') {
56
- this.backend = new DesktopOidcBackend(ctx, this.profiles, config.desktop ?? {}, backendOptions)
55
+ if (config.backend === 'desktop') {
56
+ this.backend = new GatewayDesktopBackend(ctx, this.profiles, config.desktop ?? {}, backendOptions)
57
57
  ctx.inject(['desktopServices'], inner => {
58
58
  this.backend.openExternal = url => inner.desktopServices.openExternal(url)
59
59
  inner.effect(() => () => {
@@ -64,7 +64,7 @@ export class OidcAccountService extends TypertRemoteService {
64
64
  } else {
65
65
  this.backendReady = new Promise(resolve => {
66
66
  ctx.inject(['webServer'], inner => {
67
- const backend = new WebOidcBackend(inner, this.profiles, config.web ?? {}, backendOptions)
67
+ const backend = new GatewayWebBackend(inner, this.profiles, config.web ?? {}, backendOptions)
68
68
  this.backend = backend
69
69
  resolve()
70
70
  inner.effect(() => () => { if (this.backend === backend) this.backend = undefined }, 'dsh-oidc: web backend')
@@ -114,10 +114,9 @@ export class OidcAccountService extends TypertRemoteService {
114
114
  modelResourceFetch(profileID, relativePath, options) {
115
115
  return this.callBackend('modelResourceFetch', profileID, relativePath, options)
116
116
  }
117
- // Host-only credential snapshot for optional adapters; never exposed via RPC.
118
- async resolveBoundCredential(profileID, expected) {
119
- await this.backendReady
120
- return this.backend instanceof WebOidcBackend ? this.backend.resolveBoundCredential(profileID, expected) : undefined
117
+ // Host-only readiness check for explicitly configured model-service adapters.
118
+ modelAuthorization(profileID, expectedBaseURL) {
119
+ return this.callBackend('modelAuthorization', profileID, expectedBaseURL)
121
120
  }
122
121
  async begin(profileID) {
123
122
  const result = await this.callBackend('begin', profileID)
@@ -184,6 +183,6 @@ for (const method of [
184
183
 
185
184
  export default OidcAccountService
186
185
  export { enterpriseProviderConfig, loadEnterpriseProfiles, normalizeEnterpriseProfile, publicProfile } from './profile.js'
187
- export { NativeOidcBackend, WebOidcBackend } from './oidc.js'
186
+ export { WebOidcBackend } from './oidc.js'
188
187
  export { DesktopOidcBackend } from './desktop-oidc.js'
189
188
  export { EnterpriseModelTransforms } from './provider/transforms.js'
@@ -0,0 +1,117 @@
1
+ // LiteLLM v1.101.0, native CLI auth contract 1. This is OAuth, not OIDC.
2
+ // Protocol-specific wire fields stay here; tokens never cross the renderer RPC.
3
+ const MAX_BYTES = 1024 * 1024
4
+ const MAX_TOKEN = 64 * 1024
5
+ export const protocolError = (code, message) => Object.assign(new Error(message), { code })
6
+ const nonempty = (value, max = MAX_TOKEN) => typeof value === 'string' && value.length > 0 && value.length <= max
7
+
8
+ export async function readGatewayJSON(response) {
9
+ if (Number(response.headers.get('content-length')) > MAX_BYTES) {
10
+ await response.body?.cancel()
11
+ throw protocolError('gateway_response_invalid', 'Gateway response exceeds the size limit')
12
+ }
13
+ const reader = response.body?.getReader(), chunks = []
14
+ let size = 0
15
+ if (reader) {
16
+ try {
17
+ for (;;) {
18
+ const { done, value } = await reader.read()
19
+ if (done) break
20
+ size += value.byteLength
21
+ if (size > MAX_BYTES) throw new Error('limit')
22
+ chunks.push(value)
23
+ }
24
+ } catch {
25
+ await reader.cancel().catch(() => {})
26
+ throw protocolError('gateway_response_invalid', 'Gateway response could not be read')
27
+ }
28
+ }
29
+ let raw
30
+ try { raw = JSON.parse(Buffer.concat(chunks).toString('utf8')) }
31
+ catch { throw protocolError('gateway_response_invalid', 'Gateway did not return JSON') }
32
+ if (!response.ok) {
33
+ // Never include untrusted error descriptions or echoed credentials in logs/UI.
34
+ const oauthError = ['invalid_grant', 'invalid_client', 'invalid_target', 'invalid_request', 'unsupported_grant_type'].includes(raw?.error) ? raw.error : undefined
35
+ throw Object.assign(protocolError('gateway_request_failed', `Gateway request failed (HTTP ${response.status}${oauthError ? `, ${oauthError}` : ''})`), { status: response.status, oauthError })
36
+ }
37
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw protocolError('gateway_response_invalid', 'Gateway response must be an object')
38
+ return raw
39
+ }
40
+
41
+ export async function gatewayJSON(fetcher, url, init = {}) {
42
+ let response
43
+ try {
44
+ response = await fetcher(url, { ...init, redirect: 'error', signal: init.signal ?? AbortSignal.timeout(20_000) })
45
+ } catch {
46
+ throw protocolError('gateway_unavailable', 'Gateway request did not complete; check the connection and retry')
47
+ }
48
+ return readGatewayJSON(response)
49
+ }
50
+
51
+ function trustedURL(value, profile, origin) {
52
+ let url
53
+ try { url = new URL(value) } catch { throw protocolError('gateway_discovery_invalid', 'Gateway metadata contains an invalid URL') }
54
+ const local = profile.allowInsecureDevelopment && url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)
55
+ if (!nonempty(value, 2048) || (url.protocol !== 'https:' && !local) || url.username || url.password || url.search || url.hash || (origin && url.origin !== origin)) {
56
+ throw protocolError('gateway_discovery_invalid', 'Gateway metadata URL is outside the configured trust boundary')
57
+ }
58
+ return url
59
+ }
60
+
61
+ export function detectGatewayProtocol(raw, profile) {
62
+ if (Object.hasOwn(raw, 'oidc_llm')) {
63
+ throw protocolError('gateway_protocol_unsupported', 'oidc-llm is not enabled yet; retain the existing oidc profile until the new server protocol is available')
64
+ }
65
+ if (raw.contract_version !== 1) throw protocolError('gateway_protocol_unsupported', 'Unsupported gateway discovery contract; ordinary OIDC must use the existing oidc profile')
66
+ const origin = new URL(profile.auth.discoveryUrl).origin
67
+ const issuer = trustedURL(raw.issuer, profile, origin)
68
+ if (profile.auth.expectedIssuer && raw.issuer !== profile.auth.expectedIssuer) throw protocolError('gateway_discovery_invalid', 'Gateway issuer does not match expectedIssuer')
69
+ // Native proxy resource and issuer describe the same gateway, including its prefix.
70
+ trustedURL(raw.resource, profile, origin)
71
+ if (raw.resource !== raw.issuer) throw protocolError('gateway_discovery_invalid', 'LiteLLM resource must exactly match its issuer')
72
+ for (const [key, required] of Object.entries({ response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'], code_challenge_methods_supported: ['S256'], token_endpoint_auth_methods_supported: ['none'], revocation_endpoint_auth_methods_supported: ['none'] })) {
73
+ if (!Array.isArray(raw[key]) || required.some(value => !raw[key].includes(value))) throw protocolError('gateway_discovery_invalid', `LiteLLM discovery lacks required ${key}`)
74
+ }
75
+ const result = { protocol: 'litellm-native-v1', issuer: raw.issuer, resource: raw.resource }
76
+ for (const [property, wire] of Object.entries({ authorizationEndpoint: 'authorization_endpoint', tokenEndpoint: 'token_endpoint', registrationEndpoint: 'registration_endpoint', revocationEndpoint: 'revocation_endpoint' })) {
77
+ trustedURL(raw[wire], profile, origin)
78
+ result[property] = raw[wire]
79
+ }
80
+ const base = issuer.href.replace(/\/+$/, '')
81
+ return Object.freeze({ ...result, baseURL: `${base}/v1`, userInfoEndpoint: `${base}/user/info` })
82
+ }
83
+
84
+ export async function registerLiteLLM(fetcher, descriptor, redirectURI) {
85
+ const redirect = new URL(redirectURI)
86
+ if (redirect.protocol !== 'http:' || redirect.hostname !== '127.0.0.1' || redirect.username || redirect.password || redirect.search || redirect.hash) throw protocolError('gateway_callback_invalid', 'LiteLLM requires a loopback callback')
87
+ const raw = await gatewayJSON(fetcher, descriptor.registrationEndpoint, {
88
+ method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' },
89
+ body: JSON.stringify({ client_name: 'EduWork', redirect_uris: [redirectURI], token_endpoint_auth_method: 'none', grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'] }),
90
+ })
91
+ if (!nonempty(raw.client_id) || raw.token_endpoint_auth_method !== 'none' || !Array.isArray(raw.redirect_uris) || raw.redirect_uris.length !== 1 || raw.redirect_uris[0] !== redirectURI) {
92
+ throw protocolError('gateway_registration_invalid', 'LiteLLM did not register the requested public client callback')
93
+ }
94
+ return raw.client_id
95
+ }
96
+
97
+ export function liteLLMToken(raw, now, previous) {
98
+ if (!nonempty(raw.access_token) || !nonempty(raw.refresh_token) || typeof raw.token_type !== 'string' || raw.token_type.toLowerCase() !== 'bearer' || !Number.isSafeInteger(raw.expires_in) || raw.expires_in <= 0 || raw.expires_in > 365 * 86400 || !nonempty(raw.user_id, 1024) || !(raw.team_id === null || nonempty(raw.team_id, 1024))) {
99
+ throw protocolError('gateway_token_invalid', 'LiteLLM token response is incomplete or invalid')
100
+ }
101
+ if (previous && (raw.user_id !== previous.identity.sub || raw.team_id !== previous.teamID)) throw protocolError('gateway_identity_changed', 'Gateway authorization identity changed during refresh; sign in again')
102
+ return { accessToken: raw.access_token, refreshToken: raw.refresh_token, expiresAt: Math.floor(now() / 1000) + raw.expires_in, teamID: raw.team_id, identity: { sub: raw.user_id, name: previous?.identity?.name || raw.user_id } }
103
+ }
104
+
105
+ export function tokenRequest(fetcher, descriptor, values) {
106
+ return gatewayJSON(fetcher, descriptor.tokenEndpoint, {
107
+ method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
108
+ body: new URLSearchParams({ ...values, resource: descriptor.resource }),
109
+ })
110
+ }
111
+
112
+ export async function revokeLiteLLM(fetcher, descriptor, session) {
113
+ await gatewayJSON(fetcher, descriptor.revocationEndpoint, {
114
+ method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
115
+ body: new URLSearchParams({ token: session.refreshToken, client_id: session.clientId }), signal: AbortSignal.timeout(5_000),
116
+ })
117
+ }