@eduwork/dsh-oidc 0.2.3 → 0.3.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.en.md +6 -38
  2. package/CHANGELOG.md +7 -39
  3. package/CODE_OF_CONDUCT.en.md +1 -1
  4. package/CODE_OF_CONDUCT.md +1 -1
  5. package/CONTRIBUTING.en.md +1 -1
  6. package/CONTRIBUTING.md +1 -1
  7. package/README.md +21 -130
  8. package/README_EN.md +36 -0
  9. package/SECURITY.en.md +1 -1
  10. package/SECURITY.md +1 -1
  11. package/SUPPORT.en.md +1 -1
  12. package/SUPPORT.md +1 -1
  13. package/docs/EDUWORK-MIGRATION.en.md +3 -31
  14. package/docs/EDUWORK-MIGRATION.md +3 -31
  15. package/docs/account-extensions.en.md +6 -6
  16. package/docs/account-extensions.md +3 -3
  17. package/docs/architecture.en.md +12 -117
  18. package/docs/architecture.md +12 -118
  19. package/docs/compatibility.en.md +6 -62
  20. package/docs/compatibility.md +6 -62
  21. package/docs/decisions/0001-one-plugin-composition-boundary.en.md +2 -2
  22. package/docs/decisions/0001-one-plugin-composition-boundary.md +2 -2
  23. package/docs/desktop-host.en.md +54 -55
  24. package/docs/desktop-host.md +63 -64
  25. package/docs/development.en.md +44 -44
  26. package/docs/development.md +6 -6
  27. package/docs/dsh-integration.en.md +13 -48
  28. package/docs/dsh-integration.md +14 -49
  29. package/docs/ecnu-reference.en.md +4 -61
  30. package/docs/ecnu-reference.md +4 -61
  31. package/docs/enterprise-profile.en.md +10 -31
  32. package/docs/enterprise-profile.md +11 -32
  33. package/docs/gateway-auth/README.md +168 -0
  34. package/docs/gateway-auth/README_EN.md +70 -0
  35. package/docs/gateway-auth/experimental-oidc-llm.en.md +53 -0
  36. package/docs/gateway-auth/experimental-oidc-llm.md +53 -0
  37. package/docs/gateway-auth/litellm-setup.en.md +114 -0
  38. package/docs/gateway-auth/litellm-setup.md +114 -0
  39. package/docs/gateway-auth/oidc-llm-draft.en.md +141 -0
  40. package/docs/gateway-auth/oidc-llm-draft.md +141 -0
  41. package/docs/getting-started.en.md +17 -122
  42. package/docs/getting-started.md +16 -225
  43. package/docs/key-binding-protocol.en.md +10 -220
  44. package/docs/key-binding-protocol.md +10 -220
  45. package/docs/oidc-interoperability.en.md +1 -1
  46. package/docs/oidc-interoperability.md +1 -1
  47. package/docs/open-integration.en.md +11 -0
  48. package/docs/open-integration.md +11 -0
  49. package/docs/public-resource-protocol.en.md +8 -44
  50. package/docs/public-resource-protocol.md +8 -43
  51. package/docs/release-checklist.en.md +7 -61
  52. package/docs/release-checklist.md +7 -61
  53. package/docs/security-model.en.md +10 -132
  54. package/docs/security-model.md +10 -132
  55. package/docs/server-integration-contract.en.md +12 -811
  56. package/docs/server-integration-contract.md +12 -823
  57. package/examples/ecnu.enterprise-profile.example.json +14 -19
  58. package/examples/enterprise-profile.example.json +73 -78
  59. package/examples/identity-only.example.json +1 -1
  60. package/examples/litellm.enterprise-profile.example.json +9 -0
  61. package/examples/oidc-llm.enterprise-profile.example.json +12 -0
  62. package/examples/resources-discovery.example.json +6 -9
  63. package/lib/callback-page.js +5 -5
  64. package/lib/client.js +31 -48
  65. package/lib/desktop-oidc.js +7 -11
  66. package/lib/gateway-backend.js +443 -0
  67. package/lib/gateway-protocol.js +6 -0
  68. package/lib/index.js +14 -15
  69. package/lib/litellm-protocol.js +117 -0
  70. package/lib/model-resource-transport.js +19 -0
  71. package/lib/oidc-llm-protocol.js +88 -0
  72. package/lib/oidc.js +37 -323
  73. package/lib/profile.js +34 -40
  74. package/lib/provider/authorization-scope.js +46 -0
  75. package/lib/provider/index.js +11 -2
  76. package/lib/provider/transforms.js +0 -1
  77. package/lib/typert-schemas.js +1 -1
  78. package/lib/typert.host.js +38 -38
  79. package/lib/typert.remote-client.js +37 -37
  80. package/package.json +7 -6
  81. package/protocol/openapi.yaml +33 -240
  82. package/protocol/resources.openapi.yaml +8 -9
  83. package/schema/enterprise-profile.v1alpha1.schema.json +595 -193
  84. package/README.en.md +0 -186
@@ -0,0 +1,19 @@
1
+ // Shared bounded Host-only reads for optional resource extensions.
2
+ const error = (code, message) => Object.assign(new Error(message), { code })
3
+
4
+ export function validateResourceRead(relativePath, options) {
5
+ if (typeof relativePath !== 'string' || !/^\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(relativePath)
6
+ || Object.keys(options).some(key => key !== 'signal')) throw error('oidc_resource_path_denied', 'A fixed model-resource path and read-only options are required')
7
+ }
8
+
9
+ export async function bufferResourceResponse(response, assertCurrent) {
10
+ if (Number(response.headers.get('content-length')) > 1024 * 1024) { await response.body?.cancel(); throw error('oidc_resource_invalid', 'Model resource response is too large') }
11
+ const chunks = [], reader = response.body?.getReader()
12
+ let size = 0
13
+ if (reader) {
14
+ 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) } }
15
+ catch { await reader.cancel().catch(() => {}); throw error('oidc_resource_invalid', 'Model resource response could not be read') }
16
+ }
17
+ await assertCurrent()
18
+ return new Response([204, 205, 304].includes(response.status) ? null : Buffer.concat(chunks), { status: response.status, headers: response.headers })
19
+ }
@@ -0,0 +1,88 @@
1
+ // Experimental draft 0.1. These wire rules are not a published standard.
2
+ import { protocolError } from './litellm-protocol.js'
3
+
4
+ const fail = message => { throw protocolError('gateway_discovery_invalid', message) }
5
+ const tokenText = value => typeof value === 'string' && value.length > 0 && value.length <= 65536
6
+ const includes = (raw, key, values) => {
7
+ if (!Array.isArray(raw[key]) || values.some(value => !raw[key].includes(value))) fail(`oidc-llm discovery lacks required ${key}`)
8
+ }
9
+
10
+ function endpoint(value, profile, origin) {
11
+ let url
12
+ try { url = new URL(value) } catch { fail('oidc-llm metadata contains an invalid URL') }
13
+ const development = profile.allowInsecureDevelopment && url.protocol === 'http:'
14
+ && (['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) || url.origin === profile.insecureDevelopmentOrigin)
15
+ if (typeof value !== 'string' || value.length > 2048 || (url.protocol !== 'https:' && !development)
16
+ || url.username || url.password || url.search || url.hash || origin && url.origin !== origin) fail('oidc-llm URL is outside the configured trust boundary')
17
+ return url
18
+ }
19
+
20
+ export function detectOidcLlm(raw, profile) {
21
+ if (!profile.auth.experimentalOidcLlm) throw protocolError('gateway_protocol_unsupported', 'oidc-llm requires explicit experimental opt-in')
22
+ if (raw.contract_version !== undefined) fail('Ambiguous gateway discovery contracts')
23
+ const extension = raw.oidc_llm
24
+ if (!extension || Array.isArray(extension) || extension.version !== '0.1') throw protocolError('gateway_protocol_unsupported', 'Unsupported oidc-llm draft version')
25
+ const issuer = endpoint(raw.issuer, profile)
26
+ if (profile.auth.expectedIssuer ? raw.issuer !== profile.auth.expectedIssuer : issuer.origin !== new URL(profile.auth.discoveryUrl).origin) fail('oidc-llm issuer does not match the configured trust anchor')
27
+ // An OIDC well-known document has an issuer-location relationship, even with a pin.
28
+ if (new URL(profile.auth.discoveryUrl).pathname.endsWith('/.well-known/openid-configuration')
29
+ && profile.auth.discoveryUrl !== raw.issuer.replace(/\/+$/, '') + '/.well-known/openid-configuration') fail('OIDC discovery location does not match issuer')
30
+ const resource = endpoint(extension.resource, profile)
31
+ const identityMode = profile.auth.identityMode
32
+ includes(extension, 'identity_modes_supported', ['oauth', identityMode])
33
+ includes(extension, 'client_registration_methods_supported', ['static'])
34
+ const scopes = identityMode === 'oidc'
35
+ ? ['openid', 'profile', 'offline_access', 'llm:models:read', 'llm:invoke']
36
+ : ['llm:profile', 'llm:models:read', 'llm:invoke']
37
+ for (const [key, values] of Object.entries({
38
+ response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'],
39
+ code_challenge_methods_supported: ['S256'], token_endpoint_auth_methods_supported: ['none'],
40
+ revocation_endpoint_auth_methods_supported: ['none'], scopes_supported: scopes,
41
+ })) includes(raw, key, values)
42
+ const result = {
43
+ protocol: 'oidc-llm-draft-0.1', issuer: raw.issuer, resource: extension.resource,
44
+ identityMode, clientId: profile.auth.clientId, scopes,
45
+ requireResponseIssuer: raw.authorization_response_iss_parameter_supported === true,
46
+ }
47
+ for (const [key, wire] of Object.entries({ authorizationEndpoint: 'authorization_endpoint', tokenEndpoint: 'token_endpoint', revocationEndpoint: 'revocation_endpoint' })) {
48
+ endpoint(raw[wire], profile)
49
+ result[key] = raw[wire]
50
+ }
51
+ endpoint(raw.userinfo_endpoint, profile, resource.origin)
52
+ endpoint(extension.api_base, profile, resource.origin)
53
+ result.userInfoEndpoint = raw.userinfo_endpoint
54
+ result.baseURL = extension.api_base.replace(/\/+$/, '')
55
+ if (identityMode === 'oidc') {
56
+ includes(raw, 'id_token_signing_alg_values_supported', ['RS256'])
57
+ if (!Array.isArray(raw.subject_types_supported) || !raw.subject_types_supported.some(v => ['public', 'pairwise'].includes(v))) fail('OIDC subject type is missing')
58
+ endpoint(raw.jwks_uri, profile)
59
+ result.jwksURI = raw.jwks_uri
60
+ }
61
+ return Object.freeze(result)
62
+ }
63
+
64
+ export function oidcLlmToken(raw, descriptor, now, previous) {
65
+ if (!tokenText(raw.access_token) || !tokenText(raw.refresh_token) || typeof raw.token_type !== 'string'
66
+ || raw.token_type.toLowerCase() !== 'bearer' || !Number.isSafeInteger(raw.expires_in)
67
+ || raw.expires_in <= 0 || raw.expires_in > 365 * 86400 || typeof raw.scope !== 'string') {
68
+ throw protocolError('gateway_token_invalid', 'oidc-llm token response is incomplete or invalid')
69
+ }
70
+ const scopes = raw.scope.split(' ')
71
+ const allowed = previous?.scopes ?? descriptor.scopes
72
+ if (scopes.some(scope => !scope || !allowed.includes(scope)) || new Set(scopes).size !== scopes.length
73
+ || descriptor.scopes.some(scope => !scopes.includes(scope))) {
74
+ throw protocolError('gateway_scope_changed', 'oidc-llm granted scopes do not match the requested authorization')
75
+ }
76
+ // No unapproved 15-minute ceiling: honor the actual advertised TTL.
77
+ return { accessToken: raw.access_token, refreshToken: raw.refresh_token, expiresAt: Math.floor(now() / 1000) + raw.expires_in, scopes, teamID: null }
78
+ }
79
+
80
+ export function oidcLlmIdentity(raw) {
81
+ if (typeof raw.sub !== 'string' || !/^[\x21-\x7e]{1,255}$/.test(raw.sub)) throw protocolError('oidc_userinfo_invalid', 'UserInfo has no valid subject')
82
+ for (const key of ['name', 'preferred_username', 'picture', 'email']) {
83
+ if (raw[key] !== undefined && typeof raw[key] !== 'string') throw protocolError('oidc_userinfo_invalid', 'UserInfo claim type is invalid')
84
+ }
85
+ if (raw.email_verified !== undefined && typeof raw.email_verified !== 'boolean') throw protocolError('oidc_userinfo_invalid', 'UserInfo claim type is invalid')
86
+ // Existing account UI consumes only the stable subject and display name.
87
+ return { sub: raw.sub, name: raw.name?.trim().slice(0, 256) || raw.preferred_username?.trim().slice(0, 256) || '已登录' }
88
+ }
package/lib/oidc.js CHANGED
@@ -1,6 +1,5 @@
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'
1
+ import { createHash, createPublicKey, randomBytes, timingSafeEqual, verify as verifySignature } from 'node:crypto'
2
+ import { emptyResources } from './resources.js'
4
3
 
5
4
  const FLOW_TTL_MS = 10 * 60 * 1000
6
5
  const MAX_PENDING_FLOWS = 32
@@ -74,22 +73,6 @@ function sessionRef(profile) {
74
73
  return `DSH_OIDC_${profile.id.toUpperCase().replace(/-/g, '_')}_SESSION`
75
74
  }
76
75
 
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
-
93
76
  function nowSeconds(now) {
94
77
  return Math.floor(now() / 1000)
95
78
  }
@@ -103,11 +86,11 @@ function account(profile, session, credentialReady, credentialState = '') {
103
86
  profileID: profile.id,
104
87
  displayName: profile.displayName,
105
88
  organization: profile.organization,
106
- state: session ? (credentialReady || !profile.keyBinding ? 'connected' : 'authenticated') : 'signed_out',
89
+ state: session ? 'connected' : 'signed_out',
107
90
  ...(session?.identity?.name ? { userName: session.identity.name } : {}),
108
91
  ...(session?.identity?.affiliation ? { affiliation: session.identity.affiliation } : {}),
109
92
  ...(session?.expiresAt ? { accessExpiresAt: new Date(session.expiresAt * 1000).toISOString() } : {}),
110
- credentialRef: profile.keyBinding?.credentialRef ?? '',
93
+ credentialRef: '',
111
94
  credentialReady,
112
95
  ...(credentialState ? { credentialState } : {}),
113
96
  capabilities: Array.isArray(session?.capabilities) ? session.capabilities : [],
@@ -145,7 +128,7 @@ function runtimeProjection(runtime = {}, fallback = {}) {
145
128
  function profileManagement(profiles) {
146
129
  const rows = [...profiles.values()].map(profile => ({
147
130
  id: profile.id, displayName: profile.displayName, organization: profile.organization,
148
- baseURL: profile.provider?.baseURL ?? profile.oidc.issuer, builtIn: true, configured: true, enabled: true,
131
+ baseURL: profile.provider?.baseURL || profile.auth?.discoveryUrl || profile.oidc.issuer, builtIn: true, configured: true, enabled: true,
149
132
  providerID: profile.provider?.id ?? '',
150
133
  runtime: runtimeProjection({ ...profile.provider, modelSource: profile.provider?.modelSource ?? 'none' }, profile.provider),
151
134
  }))
@@ -269,6 +252,7 @@ export class WebOidcBackend {
269
252
  }
270
253
  const result = Object.freeze({
271
254
  issuer: raw.issuer,
255
+ requireResponseIssuer: raw.authorization_response_iss_parameter_supported === true,
272
256
  authorizationEndpoint: discoveredEndpoint(raw.authorization_endpoint, profile, 'authorization_endpoint'),
273
257
  tokenEndpoint: discoveredEndpoint(raw.token_endpoint, profile, 'token_endpoint'),
274
258
  userInfoEndpoint: discoveredEndpoint(raw.userinfo_endpoint, profile, 'userinfo_endpoint'),
@@ -284,7 +268,7 @@ export class WebOidcBackend {
284
268
  const discovery = await this.discover(profile)
285
269
  this.pruneFlows()
286
270
  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)
271
+ const { authorizationURL } = await this.createAuthorization(profile, discovery, this.redirectURI)
288
272
  return { mode: 'redirect', authorizationURL }
289
273
  }
290
274
 
@@ -323,9 +307,9 @@ export class WebOidcBackend {
323
307
  const profile = this.profile(flow.profileID)
324
308
  const session = await this.exchangeAuthorization(requested, flow)
325
309
  await this.saveSession(profile, session, flow.epoch)
326
- const status = await this.reconcile(profile.id, { allowProvision: false })
310
+ const status = await this.reconcile(profile.id)
327
311
  this.accountChanged(status)
328
- outcome = status.state === 'connected' ? 'connected' : 'credential-required'
312
+ outcome = 'connected'
329
313
  } catch (cause) {
330
314
  this.ctx.logger.warn('OIDC callback failed', { code: cause?.code ?? 'oidc_callback_failed' })
331
315
  outcome = cause?.code ?? 'oidc_callback_failed'
@@ -339,11 +323,14 @@ export class WebOidcBackend {
339
323
 
340
324
  async exchangeAuthorization(requested, flow) {
341
325
  const profile = this.profile(flow.profileID)
326
+ const discovery = await this.discover(profile)
327
+ const issuers = requested.searchParams.getAll('iss')
328
+ if ((!issuers.length && discovery.requireResponseIssuer) || issuers.length > 1
329
+ || (issuers.length === 1 && !equalText(issuers[0], discovery.issuer))) {
330
+ throw publicError('oidc_callback_invalid', 'OIDC authorization response issuer is missing or invalid')
331
+ }
342
332
  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)
333
+ return this.exchangeCode(profile, discovery, singleQueryParameter(requested, 'code', true), flow.verifier, flow.nonce, flow.redirectURI)
347
334
  }
348
335
 
349
336
  async exchangeCode(profile, discovery, code, verifier, nonce, redirectURI = this.redirectURI) {
@@ -381,13 +368,14 @@ export class WebOidcBackend {
381
368
  }
382
369
  }
383
370
 
384
- async verifyIDToken(profile, discovery, raw, expectedNonce, accessToken) {
371
+ async verifyIDToken(profile, discovery, raw, expectedNonce, accessToken, { refresh = false } = {}) {
372
+ if (typeof raw !== 'string') throw publicError('oidc_id_token_invalid', 'OIDC ID Token is missing')
385
373
  const parts = raw.split('.')
386
374
  if (parts.length !== 3) throw publicError('oidc_id_token_invalid', 'OIDC ID Token is malformed')
387
375
  const header = decodePart(parts[0], 'ID Token header')
388
376
  const claims = decodePart(parts[1], 'ID Token claims')
389
377
  if (header.alg !== 'RS256' || typeof header.kid !== 'string' || header.kid === '') throw publicError('oidc_id_token_invalid', 'OIDC ID Token must use RS256 with kid')
390
- const keysResponse = await this.fetch(discovery.jwksURI, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(20_000) })
378
+ const keysResponse = await this.fetch(discovery.jwksURI, { headers: { accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(20_000) })
391
379
  const jwks = await responseJSON(keysResponse, 'oidc_jwks_failed')
392
380
  const candidates = Array.isArray(jwks.keys) ? jwks.keys.filter(key => (
393
381
  key.kid === header.kid && key.kty === 'RSA' && (key.use === undefined || key.use === 'sig')
@@ -402,8 +390,12 @@ export class WebOidcBackend {
402
390
  const now = nowSeconds(this.now)
403
391
  const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud]
404
392
  const audienceValid = audience.includes(profile.oidc.clientId)
393
+ && audience.every(value => typeof value === 'string' && value !== '')
394
+ && new Set(audience).size === audience.length
405
395
  && (audience.length === 1 || claims.azp === profile.oidc.clientId)
406
- if (claims.iss !== profile.oidc.issuer || !audienceValid || typeof claims.sub !== 'string' || claims.sub === '' || !equalText(claims.nonce, expectedNonce)) {
396
+ && (claims.azp === undefined || claims.azp === profile.oidc.clientId)
397
+ const nonceValid = refresh && claims.nonce === undefined || equalText(claims.nonce, expectedNonce)
398
+ if (claims.iss !== profile.oidc.issuer || !audienceValid || typeof claims.sub !== 'string' || claims.sub === '' || !nonceValid) {
407
399
  throw publicError('oidc_id_token_invalid', 'OIDC ID Token identity claims are invalid')
408
400
  }
409
401
  if (!Number.isFinite(claims.exp) || !Number.isFinite(claims.iat)
@@ -435,93 +427,21 @@ export class WebOidcBackend {
435
427
  try { session = JSON.parse(record.value) } catch { await this.ctx.credentials.unset(sessionRef(profile)); return undefined }
436
428
  if (session?.issuer !== profile.oidc.issuer || session?.clientId !== profile.oidc.clientId
437
429
  || typeof session?.accessToken !== 'string' || !Number.isFinite(session?.expiresAt)
438
- || typeof session?.identity?.sub !== 'string') {
439
- await this.clearOwnedCredential(profile, session)
430
+ || typeof session?.identity?.sub !== 'string' || session.resourceBinding !== undefined
431
+ || session.runtimeCredentialHash !== undefined) {
440
432
  await this.ctx.credentials.unset(sessionRef(profile))
441
433
  return undefined
442
434
  }
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)
473
435
  return session
474
436
  })
475
437
  }
476
438
 
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
439
  accountEpoch(profile) { return this.accountEpochs.get(profile.id) ?? 0 }
520
440
 
521
441
  writeCredentials(profile, epoch, operation) {
522
442
  // Serialize vault writes so logout clears even a write already in progress.
523
443
  // Network requests do not hold this queue; late replies cannot restore keys.
524
- const queue = profile.keyBinding?.credentialRef ?? sessionRef(profile)
444
+ const queue = sessionRef(profile)
525
445
  const write = (this.credentialWrites.get(queue) ?? Promise.resolve()).catch(() => {}).then(() => {
526
446
  if (epoch !== this.accountEpoch(profile)) throw publicError('oidc_login_cancelled', '账户状态已改变,请重新登录。')
527
447
  return operation()
@@ -532,18 +452,11 @@ export class WebOidcBackend {
532
452
  }
533
453
 
534
454
  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)
540
- await this.ctx.credentials.unset(sessionRef(profile))
541
- })
455
+ return this.writeCredentials(profile, epoch, () => this.ctx.credentials.unset(sessionRef(profile)))
542
456
  }
543
457
 
544
458
  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) })))
459
+ return this.writeCredentials(profile, epoch, () => this.ctx.credentials.set(sessionRef(profile), JSON.stringify(session)))
547
460
  }
548
461
 
549
462
  refresh(profile, session) {
@@ -610,12 +523,8 @@ export class WebOidcBackend {
610
523
  const profile = this.profile(profileID)
611
524
  const epoch = this.accountEpoch(profile)
612
525
  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
526
  if (epoch !== this.accountEpoch(profile)) return account(profile, undefined, false)
618
- return account(profile, session, typeof credential?.value === 'string' && credential.value !== '')
527
+ return account(profile, session, false, 'not_required')
619
528
  }
620
529
 
621
530
  async authorized(profile, session, endpoint, init = {}) {
@@ -639,7 +548,6 @@ export class WebOidcBackend {
639
548
  const target = new URL(endpoint)
640
549
  const allowed = new Set([
641
550
  new URL(profile.oidc.issuer).origin,
642
- ...(profile.keyBinding ? [new URL(profile.keyBinding.baseURL).origin] : []),
643
551
  ...this.authorizedOrigins,
644
552
  ])
645
553
  if (!allowed.has(target.origin) || target.username || target.password || target.hash) {
@@ -650,87 +558,15 @@ export class WebOidcBackend {
650
558
  return (await this.authorized(profile, session, target.toString(), init)).response
651
559
  }
652
560
 
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 })
561
+ async modelResourceFetch(profileID) {
562
+ this.profile(profileID)
563
+ throw publicError('oidc_resource_not_configured', 'Identity-only sign-in has no model resources; configure gateway authorization')
678
564
  }
679
565
 
680
- async reconcile(profileID, options = {}) {
566
+ async reconcile(profileID) {
681
567
  const profile = this.profile(profileID)
682
- const epoch = this.accountEpoch(profile)
683
- let session = await this.activeSession(profile)
684
- if (!session) return account(profile, undefined, false)
685
- if (!profile.keyBinding) return account(profile, session, false, 'not_required')
686
- const bootstrapCall = await this.authorized(profile, session, `${profile.keyBinding.baseURL}/bootstrap`)
687
- session = bootstrapCall.session
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
- }
692
- if (bootstrap.provider?.id !== profile.keyBinding.providerId) {
693
- throw publicError('oidc_binding_invalid', '身份登录已完成,但模型资源服务与配置中的 Provider ID 不一致。请检查企业配置文件。')
694
- }
695
- session = {
696
- ...session,
697
- capabilities: Array.isArray(bootstrap.capabilities) ? bootstrap.capabilities.filter(value => typeof value === 'string').slice(0, 256) : [],
698
- }
699
- await this.saveSession(profile, session, epoch)
700
- const runtime = bootstrap.runtime_credential ?? {}
701
- const state = String(runtime.status ?? '').toLowerCase()
702
- let operation = 'resolve'
703
- if (state === 'missing') {
704
- if (options.allowProvision !== true) {
705
- await this.setRuntimeCredential(profile, session, '', epoch)
706
- return account(profile, session, false, 'missing')
707
- }
708
- if (runtime.provisioning?.allowed === false) throw publicError('oidc_binding_denied', 'the organization does not allow credential provisioning')
709
- operation = 'provision'
710
- } else if (state === 'expired' || state === 'expiring') {
711
- operation = runtime.api_key_id ? 'renew' : 'resolve'
712
- } else if (state !== 'active') {
713
- await this.setRuntimeCredential(profile, session, '', epoch)
714
- return account(profile, session, false, state || 'unavailable')
715
- }
716
- const payload = { provider_id: profile.keyBinding.providerId }
717
- if (runtime.api_key_id) payload.api_key_id = runtime.api_key_id
718
- const bindingCall = await this.authorized(profile, session, `${profile.keyBinding.baseURL}/runtime-credential/${operation}`, {
719
- method: 'POST',
720
- headers: { 'content-type': 'application/json', ...(operation === 'provision' || operation === 'renew' ? { 'idempotency-key': randomUUID() } : {}) },
721
- body: JSON.stringify(payload),
722
- })
723
- session = bindingCall.session
724
- const credential = await responseJSON(bindingCall.response, 'oidc_binding_failed')
725
- if (credential.provider_id !== profile.keyBinding.providerId) throw publicError('oidc_binding_invalid', 'runtime credential provider is invalid')
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')
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')
568
+ const session = await this.activeSession(profile)
569
+ return account(profile, session, false, 'not_required')
734
570
  }
735
571
 
736
572
  resources(profileID) {
@@ -742,32 +578,7 @@ export class WebOidcBackend {
742
578
  }
743
579
 
744
580
  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
581
+ return emptyResources(this.profile(profileID))
771
582
  }
772
583
 
773
584
  management() { return Promise.resolve(profileManagement(this.profiles)) }
@@ -805,101 +616,4 @@ export class WebOidcBackend {
805
616
  }
806
617
  }
807
618
 
808
- export class NativeOidcBackend {
809
- constructor(ctx, profiles) {
810
- this.ctx = ctx
811
- this.profiles = profiles
812
- }
813
-
814
- profile(id) {
815
- const profile = this.profiles.get(id)
816
- if (!profile) throw publicError('oidc_profile_unknown', `unknown OIDC profile ${id}`)
817
- return profile
818
- }
819
-
820
- service() {
821
- const service = this.ctx.get('enterpriseAccounts')
822
- if (!service) throw publicError('oidc_native_adapter_unavailable', 'native OIDC adapter is unavailable')
823
- return service
824
- }
825
-
826
- normalize(profile, status) {
827
- if (status.runtimeCredentialRef !== undefined && status.runtimeCredentialRef !== (profile.keyBinding?.credentialRef ?? '')) {
828
- throw publicError(
829
- 'oidc_native_credential_ref_mismatch',
830
- `native OIDC adapter credential reference does not match Enterprise Profile ${profile.id}`,
831
- )
832
- }
833
- return {
834
- profileID: profile.id,
835
- displayName: status.displayName ?? profile.displayName,
836
- organization: status.organization ?? profile.organization,
837
- state: status.state ?? (status.credentialReady ? 'connected' : 'signed_out'),
838
- ...(status.userName ? { userName: status.userName } : {}),
839
- ...(status.affiliation ? { affiliation: status.affiliation } : {}),
840
- ...(status.accessExpiresAt ? { accessExpiresAt: status.accessExpiresAt } : {}),
841
- credentialRef: profile.keyBinding?.credentialRef ?? '',
842
- credentialReady: status.credentialReady === true,
843
- ...(status.credentialState ? { credentialState: status.credentialState } : {}),
844
- capabilities: Array.isArray(status.capabilities) ? status.capabilities : [],
845
- }
846
- }
847
-
848
- normalizeManagement(configuration) {
849
- const active = String(configuration?.activeInstitutionID ?? '')
850
- const institutions = Array.isArray(configuration?.institutions) ? configuration.institutions : []
851
- return {
852
- schemaVersion: 'dsh-oidc/management/v1alpha1', mode: 'native', activeProfileID: active,
853
- restartRequired: configuration?.restartRequired === true,
854
- capabilities: {
855
- manageProfiles: ['activate', 'configure', 'addCustom', 'updateCustom', 'removeInstitution'].every(method => typeof this.service()[method] === 'function'),
856
- manageModels: typeof this.service().configureCustomModels === 'function',
857
- restart: typeof this.service().restart === 'function',
858
- },
859
- profiles: institutions.map(institution => ({
860
- id: String(institution.id), displayName: String(institution.displayName), organization: String(institution.organization),
861
- baseURL: String(institution.baseURL), builtIn: institution.builtIn === true, configured: institution.configured === true,
862
- enabled: String(institution.id) === active, providerID: String(institution.providerID),
863
- runtime: runtimeProjection(institution.runtime, { displayName: institution.displayName }),
864
- })),
865
- }
866
- }
867
-
868
- async management() { return this.normalizeManagement(await this.service().configuration()) }
869
- async resources(profileID) {
870
- return emptyResources(this.profile(profileID))
871
- }
872
- async activate(profileID) { return this.normalizeManagement(await this.service().activate(profileID)) }
873
- async configure(profileID) { return this.normalizeManagement(await this.service().configure(profileID)) }
874
- async addCustom(baseURL) { return this.normalizeManagement(await this.service().addCustom(baseURL)) }
875
- async updateCustom(profileID, baseURL) { return this.normalizeManagement(await this.service().updateCustom(profileID, baseURL)) }
876
- async removeProfile(profileID) { return this.normalizeManagement(await this.service().removeInstitution(profileID)) }
877
- async configureModels(profileID, modelMode, models) {
878
- return this.normalizeManagement(await this.service().configureCustomModels(profileID, modelMode, models))
879
- }
880
- restart() { return this.service().restart() }
881
-
882
- async status(profileID) {
883
- const profile = this.profile(profileID)
884
- return this.normalize(profile, await this.service().status(profile.nativeInstitutionID))
885
- }
886
-
887
- async begin(profileID) {
888
- const profile = this.profile(profileID)
889
- const status = await this.service().login(profile.nativeInstitutionID, { allowProvision: false })
890
- return { mode: 'completed', status: this.normalize(profile, status) }
891
- }
892
-
893
- async reconcile(profileID, options = {}) {
894
- const profile = this.profile(profileID)
895
- const status = await this.service().reconcile(profile.nativeInstitutionID, { allowProvision: options.allowProvision === true })
896
- return this.normalize(profile, status)
897
- }
898
-
899
- async logout(profileID) {
900
- const profile = this.profile(profileID)
901
- return this.normalize(profile, await this.service().logout(profile.nativeInstitutionID))
902
- }
903
- }
904
-
905
619
  export { account, publicError, sessionRef }