@eduwork/dsh-oidc 0.2.4 → 0.3.0-dev.2
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/CHANGELOG.en.md +6 -0
- package/CHANGELOG.md +6 -0
- package/README.md +20 -132
- package/README_EN.md +20 -177
- package/docs/EDUWORK-MIGRATION.en.md +3 -25
- package/docs/EDUWORK-MIGRATION.md +3 -25
- package/docs/account-extensions.en.md +6 -6
- package/docs/account-extensions.md +3 -3
- package/docs/architecture.en.md +12 -117
- package/docs/architecture.md +12 -118
- package/docs/compatibility.en.md +6 -40
- package/docs/compatibility.md +6 -40
- package/docs/decisions/0001-one-plugin-composition-boundary.en.md +2 -2
- package/docs/decisions/0001-one-plugin-composition-boundary.md +2 -2
- package/docs/desktop-host.en.md +4 -5
- package/docs/desktop-host.md +8 -9
- package/docs/development.en.md +2 -2
- package/docs/development.md +2 -2
- package/docs/dsh-integration.en.md +11 -46
- package/docs/dsh-integration.md +12 -47
- package/docs/ecnu-reference.en.md +4 -61
- package/docs/ecnu-reference.md +4 -61
- package/docs/enterprise-profile.en.md +11 -32
- package/docs/enterprise-profile.md +12 -33
- package/docs/gateway-auth/README.md +168 -0
- package/docs/gateway-auth/README_EN.md +70 -0
- package/docs/gateway-auth/experimental-oidc-llm.en.md +53 -0
- package/docs/gateway-auth/experimental-oidc-llm.md +53 -0
- package/docs/gateway-auth/litellm-setup.en.md +114 -0
- package/docs/gateway-auth/litellm-setup.md +114 -0
- package/docs/gateway-auth/oidc-llm-draft.en.md +141 -0
- package/docs/gateway-auth/oidc-llm-draft.md +141 -0
- package/docs/getting-started.en.md +17 -122
- package/docs/getting-started.md +16 -225
- package/docs/key-binding-protocol.en.md +10 -220
- package/docs/key-binding-protocol.md +10 -220
- package/docs/oidc-interoperability.en.md +1 -1
- package/docs/oidc-interoperability.md +1 -1
- package/docs/open-integration.en.md +5 -67
- package/docs/open-integration.md +5 -67
- package/docs/public-resource-protocol.en.md +8 -44
- package/docs/public-resource-protocol.md +8 -43
- package/docs/security-model.en.md +10 -132
- package/docs/security-model.md +10 -132
- package/docs/server-integration-contract.en.md +12 -813
- package/docs/server-integration-contract.md +12 -825
- package/examples/ecnu.enterprise-profile.example.json +16 -20
- package/examples/enterprise-profile.example.json +16 -20
- package/examples/identity-only.example.json +7 -2
- package/examples/litellm.enterprise-profile.example.json +10 -0
- package/examples/oidc-llm.enterprise-profile.example.json +13 -0
- package/examples/resources-discovery.example.json +7 -9
- package/lib/callback-page.js +5 -5
- package/lib/client.js +31 -48
- package/lib/desktop-oidc.js +7 -11
- package/lib/gateway-backend.js +443 -0
- package/lib/gateway-protocol.js +6 -0
- package/lib/index.js +14 -15
- package/lib/litellm-protocol.js +117 -0
- package/lib/model-resource-transport.js +19 -0
- package/lib/oidc-llm-protocol.js +87 -0
- package/lib/oidc.js +40 -338
- package/lib/profile.js +42 -74
- package/lib/provider/authorization-scope.js +46 -0
- package/lib/provider/core.js +5 -16
- package/lib/provider/index.js +11 -2
- package/lib/transport.js +5 -0
- package/lib/typert-schemas.js +1 -1
- package/package.json +2 -2
- package/protocol/openapi.yaml +33 -240
- package/protocol/resources.openapi.yaml +8 -9
- package/schema/enterprise-profile.v1alpha1.schema.json +508 -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 {
|
|
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
|
-
|
|
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 === '
|
|
55
|
-
|
|
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
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
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 {
|
|
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
|
+
import { serviceProtocolAllowed } from './transport.js'
|
|
2
|
+
// LiteLLM v1.101.0, native CLI auth contract 1. This is OAuth, not OIDC.
|
|
3
|
+
// Protocol-specific wire fields stay here; tokens never cross the renderer RPC.
|
|
4
|
+
const MAX_BYTES = 1024 * 1024
|
|
5
|
+
const MAX_TOKEN = 64 * 1024
|
|
6
|
+
export const protocolError = (code, message) => Object.assign(new Error(message), { code })
|
|
7
|
+
const nonempty = (value, max = MAX_TOKEN) => typeof value === 'string' && value.length > 0 && value.length <= max
|
|
8
|
+
|
|
9
|
+
export async function readGatewayJSON(response) {
|
|
10
|
+
if (Number(response.headers.get('content-length')) > MAX_BYTES) {
|
|
11
|
+
await response.body?.cancel()
|
|
12
|
+
throw protocolError('gateway_response_invalid', 'Gateway response exceeds the size limit')
|
|
13
|
+
}
|
|
14
|
+
const reader = response.body?.getReader(), chunks = []
|
|
15
|
+
let size = 0
|
|
16
|
+
if (reader) {
|
|
17
|
+
try {
|
|
18
|
+
for (;;) {
|
|
19
|
+
const { done, value } = await reader.read()
|
|
20
|
+
if (done) break
|
|
21
|
+
size += value.byteLength
|
|
22
|
+
if (size > MAX_BYTES) throw new Error('limit')
|
|
23
|
+
chunks.push(value)
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
await reader.cancel().catch(() => {})
|
|
27
|
+
throw protocolError('gateway_response_invalid', 'Gateway response could not be read')
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
let raw
|
|
31
|
+
try { raw = JSON.parse(Buffer.concat(chunks).toString('utf8')) }
|
|
32
|
+
catch { throw protocolError('gateway_response_invalid', 'Gateway did not return JSON') }
|
|
33
|
+
if (!response.ok) {
|
|
34
|
+
// Never include untrusted error descriptions or echoed credentials in logs/UI.
|
|
35
|
+
const oauthError = ['invalid_grant', 'invalid_client', 'invalid_target', 'invalid_request', 'unsupported_grant_type'].includes(raw?.error) ? raw.error : undefined
|
|
36
|
+
throw Object.assign(protocolError('gateway_request_failed', `Gateway request failed (HTTP ${response.status}${oauthError ? `, ${oauthError}` : ''})`), { status: response.status, oauthError })
|
|
37
|
+
}
|
|
38
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw protocolError('gateway_response_invalid', 'Gateway response must be an object')
|
|
39
|
+
return raw
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function gatewayJSON(fetcher, url, init = {}) {
|
|
43
|
+
let response
|
|
44
|
+
try {
|
|
45
|
+
response = await fetcher(url, { ...init, redirect: 'error', signal: init.signal ?? AbortSignal.timeout(20_000) })
|
|
46
|
+
} catch {
|
|
47
|
+
throw protocolError('gateway_unavailable', 'Gateway request did not complete; check the connection and retry')
|
|
48
|
+
}
|
|
49
|
+
return readGatewayJSON(response)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function trustedURL(value, profile, origin) {
|
|
53
|
+
let url
|
|
54
|
+
try { url = new URL(value) } catch { throw protocolError('gateway_discovery_invalid', 'Gateway metadata contains an invalid URL') }
|
|
55
|
+
if (!nonempty(value, 2048) || !serviceProtocolAllowed(url, profile.allowInsecureDevelopment) || 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
|
+
}
|