@eduwork/dsh-oidc 0.1.0-alpha.11 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.en.md +58 -1
- package/CHANGELOG.md +58 -1
- package/CODE_OF_CONDUCT.en.md +17 -0
- package/CODE_OF_CONDUCT.md +17 -0
- package/CONTRIBUTING.en.md +56 -0
- package/CONTRIBUTING.md +56 -0
- package/GOVERNANCE.en.md +41 -0
- package/GOVERNANCE.md +41 -0
- package/MAINTAINERS.en.md +13 -0
- package/MAINTAINERS.md +13 -0
- package/README.en.md +24 -22
- package/README.md +18 -16
- package/SECURITY.en.md +45 -0
- package/SECURITY.md +45 -0
- package/SUPPORT.en.md +15 -0
- package/SUPPORT.md +15 -0
- package/THIRD_PARTY_NOTICES.en.md +7 -5
- package/THIRD_PARTY_NOTICES.md +7 -5
- package/cordis.patch.yml +2 -1
- package/docs/EDUWORK-MIGRATION.en.md +17 -5
- package/docs/EDUWORK-MIGRATION.md +17 -5
- package/docs/account-extensions.en.md +40 -0
- package/docs/account-extensions.md +42 -0
- package/docs/architecture.en.md +9 -9
- package/docs/architecture.md +11 -10
- package/docs/compatibility.en.md +13 -3
- package/docs/compatibility.md +13 -3
- package/docs/decisions/0001-one-plugin-composition-boundary.en.md +1 -1
- package/docs/decisions/0001-one-plugin-composition-boundary.md +1 -1
- package/docs/desktop-host.en.md +69 -0
- package/docs/desktop-host.md +79 -0
- package/docs/development.en.md +42 -0
- package/docs/development.md +42 -0
- package/docs/dsh-integration.en.md +24 -8
- package/docs/dsh-integration.md +24 -8
- package/docs/ecnu-reference.en.md +3 -3
- package/docs/ecnu-reference.md +3 -3
- package/docs/enterprise-profile.en.md +21 -13
- package/docs/enterprise-profile.md +20 -12
- package/docs/getting-started.en.md +13 -17
- package/docs/getting-started.md +17 -19
- package/docs/key-binding-protocol.en.md +2 -2
- package/docs/key-binding-protocol.md +2 -2
- package/docs/oidc-interoperability.en.md +3 -3
- package/docs/oidc-interoperability.md +2 -2
- package/docs/public-resource-protocol.en.md +51 -0
- package/docs/public-resource-protocol.md +50 -0
- package/docs/release-checklist.en.md +13 -8
- package/docs/release-checklist.md +13 -8
- package/docs/server-integration-contract.en.md +124 -8
- package/docs/server-integration-contract.md +136 -8
- package/examples/ecnu.enterprise-profile.example.json +2 -2
- package/examples/enterprise-profile.example.json +78 -77
- package/examples/identity-only.example.json +10 -0
- package/examples/resources-discovery.example.json +20 -0
- package/lib/callback-page.js +42 -0
- package/lib/client.js +938 -294
- package/lib/desktop-oidc.js +205 -0
- package/lib/index.js +148 -18
- package/lib/oidc.js +314 -58
- package/lib/profile.js +96 -39
- package/lib/provider/core.js +76 -15
- package/lib/provider/index.js +54 -22
- package/lib/provider/transform-adapter.js +32 -11
- package/lib/resources.js +23 -0
- package/lib/typert-schemas.js +34 -3
- package/lib/typert.host.js +42 -36
- package/lib/typert.remote-client.js +41 -35
- package/package.json +117 -23
- package/protocol/openapi.yaml +2 -2
- package/protocol/resources.openapi.yaml +51 -0
- package/schema/enterprise-profile.v1alpha1.schema.json +193 -174
package/lib/oidc.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createHash, createPublicKey, randomBytes, randomUUID, timingSafeEqual, verify as verifySignature } from 'node:crypto'
|
|
2
|
+
import { emptyResources, normalizeResourceModels } from './resources.js'
|
|
3
|
+
import { DEFAULT_CREDENTIAL_REF, legacyCredentialRefs } from './profile.js'
|
|
2
4
|
|
|
3
5
|
const FLOW_TTL_MS = 10 * 60 * 1000
|
|
4
6
|
const MAX_PENDING_FLOWS = 32
|
|
@@ -72,6 +74,22 @@ function sessionRef(profile) {
|
|
|
72
74
|
return `DSH_OIDC_${profile.id.toUpperCase().replace(/-/g, '_')}_SESSION`
|
|
73
75
|
}
|
|
74
76
|
|
|
77
|
+
function resourceBinding(profile) {
|
|
78
|
+
return profile.keyBinding ? {
|
|
79
|
+
managementBaseURL: profile.keyBinding.baseURL, runtimeBaseURL: profile.provider.baseURL,
|
|
80
|
+
providerID: profile.provider.id, credentialRef: profile.keyBinding.credentialRef,
|
|
81
|
+
} : null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function credentialHash(value) {
|
|
85
|
+
return typeof value === 'string' && value ? createHash('sha256').update(value).digest('hex') : ''
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function sameResourceBinding(left, right, ignoreRef = false) {
|
|
89
|
+
return Boolean(left && right && ['managementBaseURL', 'runtimeBaseURL', 'providerID', ...(ignoreRef ? [] : ['credentialRef'])]
|
|
90
|
+
.every(key => left[key] === right[key]))
|
|
91
|
+
}
|
|
92
|
+
|
|
75
93
|
function nowSeconds(now) {
|
|
76
94
|
return Math.floor(now() / 1000)
|
|
77
95
|
}
|
|
@@ -85,11 +103,11 @@ function account(profile, session, credentialReady, credentialState = '') {
|
|
|
85
103
|
profileID: profile.id,
|
|
86
104
|
displayName: profile.displayName,
|
|
87
105
|
organization: profile.organization,
|
|
88
|
-
state: session ? (credentialReady ? 'connected' : 'authenticated') : 'signed_out',
|
|
106
|
+
state: session ? (credentialReady || !profile.keyBinding ? 'connected' : 'authenticated') : 'signed_out',
|
|
89
107
|
...(session?.identity?.name ? { userName: session.identity.name } : {}),
|
|
90
108
|
...(session?.identity?.affiliation ? { affiliation: session.identity.affiliation } : {}),
|
|
91
109
|
...(session?.expiresAt ? { accessExpiresAt: new Date(session.expiresAt * 1000).toISOString() } : {}),
|
|
92
|
-
credentialRef: profile.keyBinding
|
|
110
|
+
credentialRef: profile.keyBinding?.credentialRef ?? '',
|
|
93
111
|
credentialReady,
|
|
94
112
|
...(credentialState ? { credentialState } : {}),
|
|
95
113
|
capabilities: Array.isArray(session?.capabilities) ? session.capabilities : [],
|
|
@@ -105,6 +123,8 @@ function runtimeModel(model) {
|
|
|
105
123
|
...(model?.maxTokens ? { maxTokens: model.maxTokens } : {}),
|
|
106
124
|
...(Array.isArray(model?.input) ? { input: [...model.input] } : {}),
|
|
107
125
|
...(typeof model?.reasoning === 'boolean' ? { reasoning: model.reasoning } : {}),
|
|
126
|
+
...(model?.defaultReasoningEffort ? { defaultReasoningEffort: String(model.defaultReasoningEffort) } : {}),
|
|
127
|
+
...(model?.reasoningEfforts === false ? { reasoningEfforts: false } : {}),
|
|
108
128
|
...(model?.reasoningEfforts && typeof model.reasoningEfforts === 'object' ? { reasoningEfforts: { ...model.reasoningEfforts } } : {}),
|
|
109
129
|
...(model?.compat && typeof model.compat === 'object' ? { compat: { ...model.compat } } : {}),
|
|
110
130
|
}
|
|
@@ -125,9 +145,9 @@ function runtimeProjection(runtime = {}, fallback = {}) {
|
|
|
125
145
|
function profileManagement(profiles) {
|
|
126
146
|
const rows = [...profiles.values()].map(profile => ({
|
|
127
147
|
id: profile.id, displayName: profile.displayName, organization: profile.organization,
|
|
128
|
-
baseURL: profile.provider.
|
|
129
|
-
providerID: profile.provider
|
|
130
|
-
runtime: runtimeProjection({ ...profile.provider, modelSource:
|
|
148
|
+
baseURL: profile.provider?.baseURL ?? profile.oidc.issuer, builtIn: true, configured: true, enabled: true,
|
|
149
|
+
providerID: profile.provider?.id ?? '',
|
|
150
|
+
runtime: runtimeProjection({ ...profile.provider, modelSource: profile.provider?.modelSource ?? 'none' }, profile.provider),
|
|
131
151
|
}))
|
|
132
152
|
return {
|
|
133
153
|
schemaVersion: 'dsh-oidc/management/v1alpha1', mode: 'profile',
|
|
@@ -193,9 +213,24 @@ export class WebOidcBackend {
|
|
|
193
213
|
this.profiles = profiles
|
|
194
214
|
this.config = config
|
|
195
215
|
this.fetch = options.fetch ?? fetch
|
|
216
|
+
this.updateProvider = options.updateProvider ?? (() => {})
|
|
217
|
+
this.accountChanged = options.accountChanged ?? (() => {})
|
|
218
|
+
if (options.authorizedOrigins !== undefined && (!Array.isArray(options.authorizedOrigins) || options.authorizedOrigins.length > 64)) throw new Error('authorizedOrigins must be a bounded origin array')
|
|
219
|
+
this.authorizedOrigins = (options.authorizedOrigins ?? []).map(value => {
|
|
220
|
+
const url = new URL(value)
|
|
221
|
+
if (url.origin !== value || url.protocol !== 'https:') throw new Error('authorizedOrigins must contain exact HTTPS origins')
|
|
222
|
+
return value
|
|
223
|
+
})
|
|
224
|
+
this.resourceStates = new Map()
|
|
225
|
+
this.resourceFlights = new Map()
|
|
226
|
+
this.refreshFlights = new Map()
|
|
227
|
+
this.accountEpochs = new Map()
|
|
228
|
+
this.credentialWrites = new Map()
|
|
229
|
+
this.reviewedProfiles = new Map(profiles)
|
|
196
230
|
this.now = options.now ?? Date.now
|
|
197
231
|
this.flows = new Map()
|
|
198
232
|
this.discovery = new Map()
|
|
233
|
+
if (options.transport === 'desktop') return
|
|
199
234
|
this.callbackBaseURL = loopbackBaseURL(config, ctx.webServer)
|
|
200
235
|
this.returnPath = normalizeReturnPath(config.returnPath, this.callbackBaseURL)
|
|
201
236
|
this.callbackPath = OIDC_CALLBACK_PATH
|
|
@@ -249,18 +284,24 @@ export class WebOidcBackend {
|
|
|
249
284
|
const discovery = await this.discover(profile)
|
|
250
285
|
this.pruneFlows()
|
|
251
286
|
if (this.flows.size >= MAX_PENDING_FLOWS) throw publicError('oidc_flow_limit', 'too many pending OIDC login attempts')
|
|
287
|
+
const { authorizationURL } = this.createAuthorization(profile, discovery, this.redirectURI)
|
|
288
|
+
return { mode: 'redirect', authorizationURL }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
createAuthorization(profile, discovery, redirectURI) {
|
|
252
292
|
const state = base64url(randomBytes(32))
|
|
253
293
|
const nonce = base64url(randomBytes(32))
|
|
254
294
|
const verifier = base64url(randomBytes(48))
|
|
255
295
|
const challenge = base64url(createHash('sha256').update(verifier).digest())
|
|
256
|
-
|
|
296
|
+
const flow = { profileID: profile.id, nonce, verifier, redirectURI, createdAt: this.now(), epoch: this.accountEpoch(profile) }
|
|
297
|
+
this.flows.set(state, flow)
|
|
257
298
|
const target = new URL(discovery.authorizationEndpoint)
|
|
258
299
|
for (const [key, value] of Object.entries({
|
|
259
|
-
response_type: 'code', client_id: profile.oidc.clientId, redirect_uri:
|
|
300
|
+
response_type: 'code', client_id: profile.oidc.clientId, redirect_uri: redirectURI,
|
|
260
301
|
scope: profile.oidc.scopes.join(' '), state, nonce, code_challenge: challenge,
|
|
261
302
|
code_challenge_method: 'S256',
|
|
262
303
|
})) target.searchParams.set(key, value)
|
|
263
|
-
return {
|
|
304
|
+
return { state, flow, authorizationURL: target.toString() }
|
|
264
305
|
}
|
|
265
306
|
|
|
266
307
|
pruneFlows() {
|
|
@@ -280,18 +321,13 @@ export class WebOidcBackend {
|
|
|
280
321
|
if (!flow || flow.createdAt < this.now() - FLOW_TTL_MS) throw publicError('oidc_callback_invalid', 'OIDC login state is missing or expired')
|
|
281
322
|
profileID = flow.profileID
|
|
282
323
|
const profile = this.profile(flow.profileID)
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
const code = singleQueryParameter(requested, 'code', true)
|
|
286
|
-
const responseIssuer = singleQueryParameter(requested, 'iss')
|
|
287
|
-
if (responseIssuer && !equalText(responseIssuer, profile.oidc.issuer)) throw publicError('oidc_callback_invalid', 'OIDC authorization response issuer is invalid')
|
|
288
|
-
const discovery = await this.discover(profile)
|
|
289
|
-
const session = await this.exchangeCode(profile, discovery, code, flow.verifier, flow.nonce)
|
|
290
|
-
await this.saveSession(profile, session)
|
|
324
|
+
const session = await this.exchangeAuthorization(requested, flow)
|
|
325
|
+
await this.saveSession(profile, session, flow.epoch)
|
|
291
326
|
const status = await this.reconcile(profile.id, { allowProvision: false })
|
|
292
|
-
|
|
327
|
+
this.accountChanged(status)
|
|
328
|
+
outcome = status.state === 'connected' ? 'connected' : 'credential-required'
|
|
293
329
|
} catch (cause) {
|
|
294
|
-
this.ctx.logger.warn(
|
|
330
|
+
this.ctx.logger.warn('OIDC callback failed', { code: cause?.code ?? 'oidc_callback_failed' })
|
|
295
331
|
outcome = cause?.code ?? 'oidc_callback_failed'
|
|
296
332
|
}
|
|
297
333
|
const target = new URL(this.returnPath, this.callbackBaseURL)
|
|
@@ -301,10 +337,19 @@ export class WebOidcBackend {
|
|
|
301
337
|
response.end()
|
|
302
338
|
}
|
|
303
339
|
|
|
304
|
-
async
|
|
340
|
+
async exchangeAuthorization(requested, flow) {
|
|
341
|
+
const profile = this.profile(flow.profileID)
|
|
342
|
+
if (singleQueryParameter(requested, 'error')) throw publicError('oidc_authorization_rejected', 'OIDC authorization was rejected')
|
|
343
|
+
const code = singleQueryParameter(requested, 'code', true)
|
|
344
|
+
const issuer = singleQueryParameter(requested, 'iss')
|
|
345
|
+
if (issuer && !equalText(issuer, profile.oidc.issuer)) throw publicError('oidc_callback_invalid', 'OIDC authorization response issuer is invalid')
|
|
346
|
+
return this.exchangeCode(profile, await this.discover(profile), code, flow.verifier, flow.nonce, flow.redirectURI)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async exchangeCode(profile, discovery, code, verifier, nonce, redirectURI = this.redirectURI) {
|
|
305
350
|
const body = new URLSearchParams({
|
|
306
351
|
grant_type: 'authorization_code', client_id: profile.oidc.clientId,
|
|
307
|
-
code, redirect_uri:
|
|
352
|
+
code, redirect_uri: redirectURI, code_verifier: verifier,
|
|
308
353
|
})
|
|
309
354
|
const response = await this.fetch(discovery.tokenEndpoint, {
|
|
310
355
|
method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
@@ -382,25 +427,138 @@ export class WebOidcBackend {
|
|
|
382
427
|
}
|
|
383
428
|
|
|
384
429
|
async loadSession(profile) {
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
430
|
+
const epoch = this.accountEpoch(profile)
|
|
431
|
+
return this.writeCredentials(profile, epoch, async () => {
|
|
432
|
+
const record = await this.ctx.credentials.resolve(sessionRef(profile))
|
|
433
|
+
if (typeof record?.value !== 'string') return undefined
|
|
434
|
+
let session
|
|
435
|
+
try { session = JSON.parse(record.value) } catch { await this.ctx.credentials.unset(sessionRef(profile)); return undefined }
|
|
436
|
+
if (session?.issuer !== profile.oidc.issuer || session?.clientId !== profile.oidc.clientId
|
|
437
|
+
|| typeof session?.accessToken !== 'string' || !Number.isFinite(session?.expiresAt)
|
|
438
|
+
|| typeof session?.identity?.sub !== 'string') {
|
|
439
|
+
await this.clearOwnedCredential(profile, session)
|
|
440
|
+
await this.ctx.credentials.unset(sessionRef(profile))
|
|
441
|
+
return undefined
|
|
442
|
+
}
|
|
443
|
+
const binding = resourceBinding(profile)
|
|
444
|
+
if (binding) {
|
|
445
|
+
const current = await this.ctx.credentials.resolve(binding.credentialRef)
|
|
446
|
+
if (!sameResourceBinding(session.resourceBinding, binding)) {
|
|
447
|
+
const oldRef = session.resourceBinding?.credentialRef
|
|
448
|
+
const renameOnly = binding.credentialRef === DEFAULT_CREDENTIAL_REF
|
|
449
|
+
&& legacyCredentialRefs(profile).includes(oldRef)
|
|
450
|
+
&& sameResourceBinding(session.resourceBinding, binding, true)
|
|
451
|
+
const old = renameOnly ? await this.ctx.credentials.resolve(oldRef) : undefined
|
|
452
|
+
if (renameOnly && !current?.value && old?.value
|
|
453
|
+
&& ![...this.profiles.values()].some(value => value.id !== profile.id && value.keyBinding?.credentialRef === oldRef)
|
|
454
|
+
&& (session.runtimeCredentialHash === undefined || session.runtimeCredentialHash === credentialHash(old.value))) {
|
|
455
|
+
await this.ctx.credentials.set(binding.credentialRef, old.value)
|
|
456
|
+
session.runtimeCredentialHash = credentialHash(old.value)
|
|
457
|
+
await this.ctx.credentials.unset(oldRef)
|
|
458
|
+
} else {
|
|
459
|
+
// A rename never adopts or overwrites an already populated target.
|
|
460
|
+
// Changed endpoints retain identity but require a fresh binding.
|
|
461
|
+
if (!renameOnly) await this.clearOwnedCredential(profile, session)
|
|
462
|
+
session.runtimeCredentialHash = ''
|
|
463
|
+
}
|
|
464
|
+
session.resourceBinding = binding
|
|
465
|
+
} else if (session.runtimeCredentialHash === undefined) {
|
|
466
|
+
// Only unambiguous pre-fingerprint sessions may adopt their old key.
|
|
467
|
+
// New logins always save an explicit empty hash until binding succeeds.
|
|
468
|
+
session.runtimeCredentialHash = this.uniqueCredentialRef(profile) ? credentialHash(current?.value) : ''
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const encoded = JSON.stringify(session)
|
|
472
|
+
if (encoded !== record.value) await this.ctx.credentials.set(sessionRef(profile), encoded)
|
|
392
473
|
return session
|
|
393
|
-
}
|
|
474
|
+
})
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
uniqueCredentialRef(profile) {
|
|
478
|
+
return [...this.profiles.values()].filter(value => value.keyBinding?.credentialRef === profile.keyBinding?.credentialRef).length === 1
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async boundCredential(profile, session) {
|
|
482
|
+
if (!session?.runtimeCredentialHash || !sameResourceBinding(session.resourceBinding, resourceBinding(profile))) return undefined
|
|
483
|
+
const credential = await this.ctx.credentials.resolve(profile.keyBinding.credentialRef)
|
|
484
|
+
return credentialHash(credential?.value) === session.runtimeCredentialHash ? credential : undefined
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async resolveBoundCredential(profileID, expected) {
|
|
488
|
+
const profile = this.profiles.get(profileID)
|
|
489
|
+
if (!profile?.keyBinding || !expected || expected.credentialRef !== profile.keyBinding.credentialRef
|
|
490
|
+
|| expected.runtimeBaseURL !== profile.provider.baseURL) return undefined
|
|
491
|
+
const epoch = this.accountEpoch(profile)
|
|
492
|
+
const credential = await this.boundCredential(profile, await this.loadSession(profile))
|
|
493
|
+
return epoch === this.accountEpoch(profile) ? credential : undefined
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async clearOwnedCredential(profile, session) {
|
|
497
|
+
if (!profile.keyBinding || session?.resourceBinding?.credentialRef !== profile.keyBinding.credentialRef) return
|
|
498
|
+
const credential = await this.ctx.credentials.resolve(profile.keyBinding.credentialRef)
|
|
499
|
+
const proven = session.runtimeCredentialHash ? credentialHash(credential?.value) === session.runtimeCredentialHash
|
|
500
|
+
: session.runtimeCredentialHash === undefined && this.uniqueCredentialRef(profile)
|
|
501
|
+
if (proven) await this.ctx.credentials.unset(profile.keyBinding.credentialRef)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
setRuntimeCredential(profile, session, value, epoch = this.accountEpoch(profile)) {
|
|
505
|
+
return this.writeCredentials(profile, epoch, async () => {
|
|
506
|
+
const record = await this.ctx.credentials.resolve(sessionRef(profile))
|
|
507
|
+
const saved = record?.value ? JSON.parse(record.value) : undefined
|
|
508
|
+
if (!saved || saved.issuer !== session.issuer || saved.clientId !== session.clientId
|
|
509
|
+
|| saved.identity?.sub !== session.identity?.sub || !sameResourceBinding(saved.resourceBinding, resourceBinding(profile))) {
|
|
510
|
+
throw publicError('oidc_login_cancelled', '账户状态已改变,请重新登录。')
|
|
511
|
+
}
|
|
512
|
+
if (value) await this.ctx.credentials.set(profile.keyBinding.credentialRef, value)
|
|
513
|
+
else await this.clearOwnedCredential(profile, saved)
|
|
514
|
+
saved.runtimeCredentialHash = credentialHash(value)
|
|
515
|
+
await this.ctx.credentials.set(sessionRef(profile), JSON.stringify(saved))
|
|
516
|
+
})
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
accountEpoch(profile) { return this.accountEpochs.get(profile.id) ?? 0 }
|
|
520
|
+
|
|
521
|
+
writeCredentials(profile, epoch, operation) {
|
|
522
|
+
// Serialize vault writes so logout clears even a write already in progress.
|
|
523
|
+
// Network requests do not hold this queue; late replies cannot restore keys.
|
|
524
|
+
const queue = profile.keyBinding?.credentialRef ?? sessionRef(profile)
|
|
525
|
+
const write = (this.credentialWrites.get(queue) ?? Promise.resolve()).catch(() => {}).then(() => {
|
|
526
|
+
if (epoch !== this.accountEpoch(profile)) throw publicError('oidc_login_cancelled', '账户状态已改变,请重新登录。')
|
|
527
|
+
return operation()
|
|
528
|
+
})
|
|
529
|
+
this.credentialWrites.set(queue, write)
|
|
530
|
+
void write.finally(() => { if (this.credentialWrites.get(queue) === write) this.credentialWrites.delete(queue) }).catch(() => {})
|
|
531
|
+
return write
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
clearCredentials(profile, epoch) {
|
|
535
|
+
return this.writeCredentials(profile, epoch, async () => {
|
|
536
|
+
const record = await this.ctx.credentials.resolve(sessionRef(profile))
|
|
537
|
+
let saved
|
|
538
|
+
try { saved = JSON.parse(record?.value) } catch { /* Malformed records cannot prove key ownership. */ }
|
|
539
|
+
await this.clearOwnedCredential(profile, saved)
|
|
394
540
|
await this.ctx.credentials.unset(sessionRef(profile))
|
|
395
|
-
|
|
396
|
-
|
|
541
|
+
})
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
saveSession(profile, session, epoch = this.accountEpoch(profile)) {
|
|
545
|
+
return this.writeCredentials(profile, epoch, () => this.ctx.credentials.set(sessionRef(profile), JSON.stringify({ ...session,
|
|
546
|
+
runtimeCredentialHash: session.runtimeCredentialHash ?? '', resourceBinding: resourceBinding(profile) })))
|
|
397
547
|
}
|
|
398
548
|
|
|
399
|
-
|
|
400
|
-
|
|
549
|
+
refresh(profile, session) {
|
|
550
|
+
if (!this.refreshFlights.has(profile.id)) {
|
|
551
|
+
const flight = this.performRefresh(profile, session).finally(() => { if (this.refreshFlights.get(profile.id) === flight) this.refreshFlights.delete(profile.id) })
|
|
552
|
+
this.refreshFlights.set(profile.id, flight)
|
|
553
|
+
}
|
|
554
|
+
return this.refreshFlights.get(profile.id)
|
|
401
555
|
}
|
|
402
556
|
|
|
403
|
-
async
|
|
557
|
+
async performRefresh(profile, session) {
|
|
558
|
+
const epoch = this.accountEpoch(profile)
|
|
559
|
+
const saved = await this.loadSession(profile)
|
|
560
|
+
if (!saved) throw publicError('oidc_login_required', 'OIDC session is no longer available')
|
|
561
|
+
if (saved && saved.accessToken !== session.accessToken && saved.expiresAt > nowSeconds(this.now) + ACCESS_REFRESH_SECONDS) return saved
|
|
404
562
|
if (!session.refreshToken) throw publicError('oidc_login_required', 'OIDC session cannot be refreshed')
|
|
405
563
|
const discovery = await this.discover(profile)
|
|
406
564
|
const response = await this.fetch(discovery.tokenEndpoint, {
|
|
@@ -412,10 +570,8 @@ export class WebOidcBackend {
|
|
|
412
570
|
try { token = await responseJSON(response, 'oidc_refresh_failed') }
|
|
413
571
|
catch (cause) {
|
|
414
572
|
if (cause.oauthError === 'invalid_grant') {
|
|
415
|
-
await
|
|
416
|
-
|
|
417
|
-
this.ctx.credentials.unset(profile.keyBinding.credentialRef),
|
|
418
|
-
])
|
|
573
|
+
await this.clearCredentials(profile, epoch)
|
|
574
|
+
this.accountChanged(account(profile, undefined, false))
|
|
419
575
|
throw publicError('oidc_login_required', 'OIDC refresh token is no longer valid', cause)
|
|
420
576
|
}
|
|
421
577
|
throw cause
|
|
@@ -431,19 +587,18 @@ export class WebOidcBackend {
|
|
|
431
587
|
refreshToken: typeof token.refresh_token === 'string' ? token.refresh_token : session.refreshToken,
|
|
432
588
|
expiresAt: nowSeconds(this.now) + Math.max(60, expiresIn),
|
|
433
589
|
}
|
|
434
|
-
await this.saveSession(profile, next)
|
|
590
|
+
await this.saveSession(profile, next, epoch)
|
|
435
591
|
return next
|
|
436
592
|
}
|
|
437
593
|
|
|
438
594
|
async activeSession(profile) {
|
|
595
|
+
const epoch = this.accountEpoch(profile)
|
|
439
596
|
let session = await this.loadSession(profile)
|
|
440
597
|
if (!session) return undefined
|
|
441
598
|
if (session.expiresAt <= nowSeconds(this.now) + ACCESS_REFRESH_SECONDS) {
|
|
442
599
|
if (!session.refreshToken) {
|
|
443
|
-
await
|
|
444
|
-
|
|
445
|
-
this.ctx.credentials.unset(profile.keyBinding.credentialRef),
|
|
446
|
-
])
|
|
600
|
+
await this.clearCredentials(profile, epoch)
|
|
601
|
+
this.accountChanged(account(profile, undefined, false))
|
|
447
602
|
return undefined
|
|
448
603
|
}
|
|
449
604
|
session = await this.refresh(profile, session)
|
|
@@ -453,16 +608,21 @@ export class WebOidcBackend {
|
|
|
453
608
|
|
|
454
609
|
async status(profileID) {
|
|
455
610
|
const profile = this.profile(profileID)
|
|
456
|
-
const
|
|
457
|
-
|
|
458
|
-
|
|
611
|
+
const epoch = this.accountEpoch(profile)
|
|
612
|
+
const session = await this.loadSession(profile)
|
|
613
|
+
const credential = profile.keyBinding ? await this.boundCredential(profile, session) : undefined
|
|
614
|
+
if (session && credential?.value && profile.provider?.modelSource === 'discovery' && !this.resourceStates.has(profileID)) {
|
|
615
|
+
await this.resources(profileID)
|
|
616
|
+
}
|
|
617
|
+
if (epoch !== this.accountEpoch(profile)) return account(profile, undefined, false)
|
|
459
618
|
return account(profile, session, typeof credential?.value === 'string' && credential.value !== '')
|
|
460
619
|
}
|
|
461
620
|
|
|
462
621
|
async authorized(profile, session, endpoint, init = {}) {
|
|
463
622
|
const request = current => this.fetch(endpoint, {
|
|
464
623
|
...init,
|
|
465
|
-
headers: { ...(init.headers
|
|
624
|
+
headers: { ...Object.fromEntries(new Headers(init.headers).entries()), authorization: `Bearer ${current.accessToken}`, accept: 'application/json' },
|
|
625
|
+
redirect: 'error',
|
|
466
626
|
signal: init.signal ?? AbortSignal.timeout(20_000),
|
|
467
627
|
})
|
|
468
628
|
let current = session
|
|
@@ -474,27 +634,75 @@ export class WebOidcBackend {
|
|
|
474
634
|
return { response, session: current }
|
|
475
635
|
}
|
|
476
636
|
|
|
637
|
+
async authorizedFetch(profileID, endpoint, init = {}) {
|
|
638
|
+
const profile = this.profile(profileID)
|
|
639
|
+
const target = new URL(endpoint)
|
|
640
|
+
const allowed = new Set([
|
|
641
|
+
new URL(profile.oidc.issuer).origin,
|
|
642
|
+
...(profile.keyBinding ? [new URL(profile.keyBinding.baseURL).origin] : []),
|
|
643
|
+
...this.authorizedOrigins,
|
|
644
|
+
])
|
|
645
|
+
if (!allowed.has(target.origin) || target.username || target.password || target.hash) {
|
|
646
|
+
throw publicError('oidc_authorized_origin_denied', 'authorized request must use a configured organization origin')
|
|
647
|
+
}
|
|
648
|
+
const session = await this.activeSession(profile)
|
|
649
|
+
if (!session) throw publicError('oidc_login_required', 'organization sign-in is required')
|
|
650
|
+
return (await this.authorized(profile, session, target.toString(), init)).response
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** Host-only read transport for optional account extensions. No business paths. */
|
|
654
|
+
async modelResourceFetch(profileID, relativePath, options = {}) {
|
|
655
|
+
const profile = this.profile(profileID), epoch = this.accountEpoch(profile)
|
|
656
|
+
if (!profile.provider || !profile.keyBinding) throw publicError('oidc_resource_not_configured', 'Organization model resources are not configured')
|
|
657
|
+
if (typeof relativePath !== 'string' || !/^\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(relativePath)
|
|
658
|
+
|| Object.keys(options).some(key => key !== 'signal')) throw publicError('oidc_resource_path_denied', 'A fixed model-resource path and read-only options are required')
|
|
659
|
+
const session = await this.activeSession(profile)
|
|
660
|
+
const credential = await this.boundCredential(profile, session)
|
|
661
|
+
if (!session || !credential?.value || epoch !== this.accountEpoch(profile)) throw publicError('oidc_login_required', 'Organization model sign-in is required')
|
|
662
|
+
const response = await this.fetch(`${profile.provider.baseURL}${relativePath}`, {
|
|
663
|
+
method: 'GET', headers: { authorization: `Bearer ${credential.value}`, accept: 'application/json' },
|
|
664
|
+
redirect: 'error', signal: AbortSignal.any([AbortSignal.timeout(20_000), ...(options.signal ? [options.signal] : [])]),
|
|
665
|
+
})
|
|
666
|
+
// Complete the bounded body while the request signal is active. An extension
|
|
667
|
+
// never receives a response belonging to a signed-out or replaced account.
|
|
668
|
+
if (Number(response.headers.get('content-length')) > 1024 * 1024) { await response.body?.cancel(); throw publicError('oidc_resource_invalid', 'Model resource response is too large') }
|
|
669
|
+
const chunks = [], reader = response.body?.getReader()
|
|
670
|
+
let size = 0
|
|
671
|
+
if (reader) {
|
|
672
|
+
try { for (;;) { const { done, value } = await reader.read(); if (done) break; size += value.length; if (size > 1024 * 1024) throw new Error('size'); chunks.push(value) } }
|
|
673
|
+
catch { await reader.cancel().catch(() => {}); throw publicError('oidc_resource_invalid', 'Model resource response could not be read') }
|
|
674
|
+
}
|
|
675
|
+
const currentCredential = await this.ctx.credentials.resolve(profile.keyBinding.credentialRef)
|
|
676
|
+
if (epoch !== this.accountEpoch(profile) || currentCredential?.value !== credential.value) throw publicError('oidc_login_cancelled', 'Organization account changed during resource request')
|
|
677
|
+
return new Response([204, 205, 304].includes(response.status) ? null : Buffer.concat(chunks), { status: response.status, headers: response.headers })
|
|
678
|
+
}
|
|
679
|
+
|
|
477
680
|
async reconcile(profileID, options = {}) {
|
|
478
681
|
const profile = this.profile(profileID)
|
|
682
|
+
const epoch = this.accountEpoch(profile)
|
|
479
683
|
let session = await this.activeSession(profile)
|
|
480
684
|
if (!session) return account(profile, undefined, false)
|
|
685
|
+
if (!profile.keyBinding) return account(profile, session, false, 'not_required')
|
|
481
686
|
const bootstrapCall = await this.authorized(profile, session, `${profile.keyBinding.baseURL}/bootstrap`)
|
|
482
687
|
session = bootstrapCall.session
|
|
483
688
|
const bootstrap = await responseJSON(bootstrapCall.response, 'oidc_binding_bootstrap_failed')
|
|
689
|
+
if (bootstrap.protocol_version !== undefined && !['worker.user-center.v1', 'worker-user-center/v1', 'eduwork-resources/v1'].includes(bootstrap.protocol_version)) {
|
|
690
|
+
throw publicError('oidc_binding_invalid', '身份登录已完成,但模型资源服务的协议版本不受支持。请联系服务管理员检查接口版本。')
|
|
691
|
+
}
|
|
484
692
|
if (bootstrap.provider?.id !== profile.keyBinding.providerId) {
|
|
485
|
-
throw publicError('oidc_binding_invalid', '
|
|
693
|
+
throw publicError('oidc_binding_invalid', '身份登录已完成,但模型资源服务与配置中的 Provider ID 不一致。请检查企业配置文件。')
|
|
486
694
|
}
|
|
487
695
|
session = {
|
|
488
696
|
...session,
|
|
489
697
|
capabilities: Array.isArray(bootstrap.capabilities) ? bootstrap.capabilities.filter(value => typeof value === 'string').slice(0, 256) : [],
|
|
490
698
|
}
|
|
491
|
-
await this.saveSession(profile, session)
|
|
699
|
+
await this.saveSession(profile, session, epoch)
|
|
492
700
|
const runtime = bootstrap.runtime_credential ?? {}
|
|
493
701
|
const state = String(runtime.status ?? '').toLowerCase()
|
|
494
702
|
let operation = 'resolve'
|
|
495
703
|
if (state === 'missing') {
|
|
496
704
|
if (options.allowProvision !== true) {
|
|
497
|
-
await this.
|
|
705
|
+
await this.setRuntimeCredential(profile, session, '', epoch)
|
|
498
706
|
return account(profile, session, false, 'missing')
|
|
499
707
|
}
|
|
500
708
|
if (runtime.provisioning?.allowed === false) throw publicError('oidc_binding_denied', 'the organization does not allow credential provisioning')
|
|
@@ -502,7 +710,7 @@ export class WebOidcBackend {
|
|
|
502
710
|
} else if (state === 'expired' || state === 'expiring') {
|
|
503
711
|
operation = runtime.api_key_id ? 'renew' : 'resolve'
|
|
504
712
|
} else if (state !== 'active') {
|
|
505
|
-
await this.
|
|
713
|
+
await this.setRuntimeCredential(profile, session, '', epoch)
|
|
506
714
|
return account(profile, session, false, state || 'unavailable')
|
|
507
715
|
}
|
|
508
716
|
const payload = { provider_id: profile.keyBinding.providerId }
|
|
@@ -516,8 +724,50 @@ export class WebOidcBackend {
|
|
|
516
724
|
const credential = await responseJSON(bindingCall.response, 'oidc_binding_failed')
|
|
517
725
|
if (credential.provider_id !== profile.keyBinding.providerId) throw publicError('oidc_binding_invalid', 'runtime credential provider is invalid')
|
|
518
726
|
if (typeof credential.api_key !== 'string' || credential.api_key === '' || credential.api_key.length > 16 * 1024) throw publicError('oidc_binding_invalid', 'runtime credential response has an invalid API key')
|
|
519
|
-
await this.
|
|
520
|
-
|
|
727
|
+
await this.setRuntimeCredential(profile, session, credential.api_key, epoch)
|
|
728
|
+
await this.resources(profileID)
|
|
729
|
+
if (epoch !== this.accountEpoch(profile)) throw publicError('oidc_login_cancelled', '账户状态已改变,请重新登录。')
|
|
730
|
+
const current = await this.resolveBoundCredential(profile.id, {
|
|
731
|
+
credentialRef: profile.keyBinding.credentialRef, runtimeBaseURL: profile.provider.baseURL,
|
|
732
|
+
})
|
|
733
|
+
return account(profile, session, Boolean(current?.value), current ? credential.status ?? 'active' : 'replaced')
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
resources(profileID) {
|
|
737
|
+
if (!this.resourceFlights.has(profileID)) {
|
|
738
|
+
const flight = this.readResources(profileID).finally(() => { if (this.resourceFlights.get(profileID) === flight) this.resourceFlights.delete(profileID) })
|
|
739
|
+
this.resourceFlights.set(profileID, flight)
|
|
740
|
+
}
|
|
741
|
+
return this.resourceFlights.get(profileID)
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
async readResources(profileID) {
|
|
745
|
+
const profile = this.profile(profileID)
|
|
746
|
+
const epoch = this.accountEpoch(profile)
|
|
747
|
+
const result = emptyResources(profile)
|
|
748
|
+
if (!profile.keyBinding) return result
|
|
749
|
+
const session = await this.loadSession(profile)
|
|
750
|
+
const credential = await this.boundCredential(profile, session)
|
|
751
|
+
if (!credential?.value || epoch !== this.accountEpoch(profile)) return result
|
|
752
|
+
const request = async path => responseJSON(await this.fetch(`${profile.provider.baseURL}/${path}`, {
|
|
753
|
+
headers: { authorization: `Bearer ${credential.value}`, accept: 'application/json' },
|
|
754
|
+
signal: AbortSignal.timeout(20_000), redirect: 'error',
|
|
755
|
+
}), `oidc_resource_${path}_failed`)
|
|
756
|
+
const operations = []
|
|
757
|
+
if (profile.provider.modelSource === 'discovery') operations.push((async () => {
|
|
758
|
+
try {
|
|
759
|
+
const models = normalizeResourceModels(await request('models'), this.reviewedProfiles.get(profileID))
|
|
760
|
+
if (epoch !== this.accountEpoch(profile) || (await this.ctx.credentials.resolve(profile.keyBinding.credentialRef))?.value !== credential.value) return
|
|
761
|
+
const next = Object.freeze({ ...profile, provider: Object.freeze({ ...profile.provider, models }) })
|
|
762
|
+
await this.updateProvider(next)
|
|
763
|
+
this.profiles.set(profileID, next)
|
|
764
|
+
result.models = models
|
|
765
|
+
} catch { result.issues.push('models_unavailable') }
|
|
766
|
+
})())
|
|
767
|
+
await Promise.all(operations)
|
|
768
|
+
if (epoch !== this.accountEpoch(profile)) return emptyResources(profile)
|
|
769
|
+
this.resourceStates.set(profileID, result)
|
|
770
|
+
return result
|
|
521
771
|
}
|
|
522
772
|
|
|
523
773
|
management() { return Promise.resolve(profileManagement(this.profiles)) }
|
|
@@ -531,7 +781,14 @@ export class WebOidcBackend {
|
|
|
531
781
|
|
|
532
782
|
async logout(profileID) {
|
|
533
783
|
const profile = this.profile(profileID)
|
|
784
|
+
const epoch = this.accountEpoch(profile) + 1
|
|
785
|
+
this.accountEpochs.set(profileID, epoch)
|
|
786
|
+
this.refreshFlights.delete(profileID)
|
|
787
|
+
this.resourceFlights.delete(profileID)
|
|
534
788
|
const session = await this.loadSession(profile)
|
|
789
|
+
await this.clearCredentials(profile, epoch)
|
|
790
|
+
this.resourceStates.delete(profileID)
|
|
791
|
+
for (const [state, flow] of this.flows) if (flow.profileID === profileID) this.flows.delete(state)
|
|
535
792
|
if (session) {
|
|
536
793
|
try {
|
|
537
794
|
const discovery = await this.discover(profile)
|
|
@@ -544,10 +801,6 @@ export class WebOidcBackend {
|
|
|
544
801
|
}
|
|
545
802
|
} catch (cause) { this.ctx.logger.warn(cause instanceof Error ? cause : new Error(String(cause))) }
|
|
546
803
|
}
|
|
547
|
-
await Promise.all([
|
|
548
|
-
this.ctx.credentials.unset(sessionRef(profile)),
|
|
549
|
-
this.ctx.credentials.unset(profile.keyBinding.credentialRef),
|
|
550
|
-
])
|
|
551
804
|
return account(profile, undefined, false)
|
|
552
805
|
}
|
|
553
806
|
}
|
|
@@ -571,7 +824,7 @@ export class NativeOidcBackend {
|
|
|
571
824
|
}
|
|
572
825
|
|
|
573
826
|
normalize(profile, status) {
|
|
574
|
-
if (status.runtimeCredentialRef !== undefined && status.runtimeCredentialRef !== profile.keyBinding
|
|
827
|
+
if (status.runtimeCredentialRef !== undefined && status.runtimeCredentialRef !== (profile.keyBinding?.credentialRef ?? '')) {
|
|
575
828
|
throw publicError(
|
|
576
829
|
'oidc_native_credential_ref_mismatch',
|
|
577
830
|
`native OIDC adapter credential reference does not match Enterprise Profile ${profile.id}`,
|
|
@@ -585,7 +838,7 @@ export class NativeOidcBackend {
|
|
|
585
838
|
...(status.userName ? { userName: status.userName } : {}),
|
|
586
839
|
...(status.affiliation ? { affiliation: status.affiliation } : {}),
|
|
587
840
|
...(status.accessExpiresAt ? { accessExpiresAt: status.accessExpiresAt } : {}),
|
|
588
|
-
credentialRef: profile.keyBinding
|
|
841
|
+
credentialRef: profile.keyBinding?.credentialRef ?? '',
|
|
589
842
|
credentialReady: status.credentialReady === true,
|
|
590
843
|
...(status.credentialState ? { credentialState: status.credentialState } : {}),
|
|
591
844
|
capabilities: Array.isArray(status.capabilities) ? status.capabilities : [],
|
|
@@ -613,6 +866,9 @@ export class NativeOidcBackend {
|
|
|
613
866
|
}
|
|
614
867
|
|
|
615
868
|
async management() { return this.normalizeManagement(await this.service().configuration()) }
|
|
869
|
+
async resources(profileID) {
|
|
870
|
+
return emptyResources(this.profile(profileID))
|
|
871
|
+
}
|
|
616
872
|
async activate(profileID) { return this.normalizeManagement(await this.service().activate(profileID)) }
|
|
617
873
|
async configure(profileID) { return this.normalizeManagement(await this.service().configure(profileID)) }
|
|
618
874
|
async addCustom(baseURL) { return this.normalizeManagement(await this.service().addCustom(baseURL)) }
|