@eduwork/dsh-oidc 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.en.md +40 -1
  2. package/CHANGELOG.md +40 -1
  3. package/README.en.md +23 -21
  4. package/README.md +17 -15
  5. package/THIRD_PARTY_NOTICES.en.md +7 -5
  6. package/THIRD_PARTY_NOTICES.md +7 -5
  7. package/cordis.patch.yml +2 -1
  8. package/docs/EDUWORK-MIGRATION.en.md +14 -2
  9. package/docs/EDUWORK-MIGRATION.md +14 -2
  10. package/docs/account-extensions.en.md +40 -0
  11. package/docs/account-extensions.md +42 -0
  12. package/docs/architecture.en.md +9 -9
  13. package/docs/architecture.md +11 -10
  14. package/docs/compatibility.en.md +11 -2
  15. package/docs/compatibility.md +11 -2
  16. package/docs/desktop-host.en.md +69 -0
  17. package/docs/desktop-host.md +79 -0
  18. package/docs/development.en.md +42 -0
  19. package/docs/development.md +42 -0
  20. package/docs/dsh-integration.en.md +23 -7
  21. package/docs/dsh-integration.md +23 -7
  22. package/docs/ecnu-reference.en.md +3 -3
  23. package/docs/ecnu-reference.md +3 -3
  24. package/docs/enterprise-profile.en.md +19 -11
  25. package/docs/enterprise-profile.md +18 -10
  26. package/docs/getting-started.en.md +12 -12
  27. package/docs/getting-started.md +16 -14
  28. package/docs/key-binding-protocol.en.md +2 -2
  29. package/docs/key-binding-protocol.md +2 -2
  30. package/docs/oidc-interoperability.en.md +3 -3
  31. package/docs/oidc-interoperability.md +2 -2
  32. package/docs/public-resource-protocol.en.md +51 -0
  33. package/docs/public-resource-protocol.md +50 -0
  34. package/docs/release-checklist.en.md +4 -2
  35. package/docs/release-checklist.md +4 -2
  36. package/docs/server-integration-contract.en.md +124 -8
  37. package/docs/server-integration-contract.md +136 -8
  38. package/examples/ecnu.enterprise-profile.example.json +2 -2
  39. package/examples/enterprise-profile.example.json +78 -77
  40. package/examples/identity-only.example.json +10 -0
  41. package/examples/resources-discovery.example.json +20 -0
  42. package/lib/callback-page.js +42 -0
  43. package/lib/client.js +935 -295
  44. package/lib/desktop-oidc.js +205 -0
  45. package/lib/index.js +148 -18
  46. package/lib/oidc.js +314 -58
  47. package/lib/profile.js +53 -24
  48. package/lib/provider/core.js +76 -15
  49. package/lib/provider/index.js +33 -20
  50. package/lib/provider/transform-adapter.js +31 -10
  51. package/lib/resources.js +23 -0
  52. package/lib/typert-schemas.js +34 -3
  53. package/lib/typert.host.js +42 -36
  54. package/lib/typert.remote-client.js +41 -35
  55. package/package.json +100 -29
  56. package/protocol/openapi.yaml +2 -2
  57. package/protocol/resources.openapi.yaml +51 -0
  58. package/schema/enterprise-profile.v1alpha1.schema.json +193 -188
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.credentialRef,
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.baseURL, builtIn: true, configured: true, enabled: true,
129
- providerID: profile.provider.id,
130
- runtime: runtimeProjection({ ...profile.provider, modelSource: 'profile' }, profile.provider),
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
- this.flows.set(state, { profileID, nonce, verifier, createdAt: this.now() })
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: this.redirectURI,
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 { mode: 'redirect', authorizationURL: target.toString() }
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 oauthError = singleQueryParameter(requested, 'error')
284
- if (oauthError) throw publicError('oidc_authorization_rejected', `OIDC authorization returned ${oauthError}`)
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
- outcome = status.credentialReady ? 'connected' : 'credential-required'
327
+ this.accountChanged(status)
328
+ outcome = status.state === 'connected' ? 'connected' : 'credential-required'
293
329
  } catch (cause) {
294
- this.ctx.logger.warn(cause instanceof Error ? cause : new Error(String(cause)))
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 exchangeCode(profile, discovery, code, verifier, nonce) {
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: this.redirectURI, code_verifier: verifier,
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 record = await this.ctx.credentials.resolve(sessionRef(profile))
386
- if (typeof record?.value !== 'string') return undefined
387
- try {
388
- const session = JSON.parse(record.value)
389
- if (session.issuer !== profile.oidc.issuer || session.clientId !== profile.oidc.clientId
390
- || typeof session.accessToken !== 'string' || !Number.isFinite(session.expiresAt)
391
- || typeof session.identity?.sub !== 'string') throw new Error('session binding mismatch')
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
- } catch {
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
- return undefined
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
- saveSession(profile, session) {
400
- return this.ctx.credentials.set(sessionRef(profile), JSON.stringify(session))
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 refresh(profile, session) {
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 Promise.all([
416
- this.ctx.credentials.unset(sessionRef(profile)),
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 Promise.all([
444
- this.ctx.credentials.unset(sessionRef(profile)),
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 [session, credential] = await Promise.all([
457
- this.loadSession(profile), this.ctx.credentials.resolve(profile.keyBinding.credentialRef),
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 ?? {}), authorization: `Bearer ${current.accessToken}`, accept: 'application/json' },
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', 'key binding provider does not match the Enterprise Profile')
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.ctx.credentials.unset(profile.keyBinding.credentialRef)
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.ctx.credentials.unset(profile.keyBinding.credentialRef)
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.ctx.credentials.set(profile.keyBinding.credentialRef, credential.api_key)
520
- return account(profile, session, true, credential.status ?? 'active')
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.credentialRef) {
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.credentialRef,
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)) }