@eduwork/dsh-oidc 0.2.4 → 0.3.0-dev.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.en.md +6 -0
  2. package/CHANGELOG.md +6 -0
  3. package/README.md +20 -132
  4. package/README_EN.md +20 -177
  5. package/docs/EDUWORK-MIGRATION.en.md +3 -25
  6. package/docs/EDUWORK-MIGRATION.md +3 -25
  7. package/docs/account-extensions.en.md +6 -6
  8. package/docs/account-extensions.md +3 -3
  9. package/docs/architecture.en.md +12 -117
  10. package/docs/architecture.md +12 -118
  11. package/docs/compatibility.en.md +6 -40
  12. package/docs/compatibility.md +6 -40
  13. package/docs/decisions/0001-one-plugin-composition-boundary.en.md +2 -2
  14. package/docs/decisions/0001-one-plugin-composition-boundary.md +2 -2
  15. package/docs/desktop-host.en.md +4 -5
  16. package/docs/desktop-host.md +8 -9
  17. package/docs/development.en.md +2 -2
  18. package/docs/development.md +2 -2
  19. package/docs/dsh-integration.en.md +11 -46
  20. package/docs/dsh-integration.md +12 -47
  21. package/docs/ecnu-reference.en.md +4 -61
  22. package/docs/ecnu-reference.md +4 -61
  23. package/docs/enterprise-profile.en.md +11 -32
  24. package/docs/enterprise-profile.md +12 -33
  25. package/docs/gateway-auth/README.md +168 -0
  26. package/docs/gateway-auth/README_EN.md +70 -0
  27. package/docs/gateway-auth/experimental-oidc-llm.en.md +53 -0
  28. package/docs/gateway-auth/experimental-oidc-llm.md +53 -0
  29. package/docs/gateway-auth/litellm-setup.en.md +114 -0
  30. package/docs/gateway-auth/litellm-setup.md +114 -0
  31. package/docs/gateway-auth/oidc-llm-draft.en.md +141 -0
  32. package/docs/gateway-auth/oidc-llm-draft.md +141 -0
  33. package/docs/getting-started.en.md +17 -122
  34. package/docs/getting-started.md +16 -225
  35. package/docs/key-binding-protocol.en.md +10 -220
  36. package/docs/key-binding-protocol.md +10 -220
  37. package/docs/oidc-interoperability.en.md +1 -1
  38. package/docs/oidc-interoperability.md +1 -1
  39. package/docs/open-integration.en.md +5 -67
  40. package/docs/open-integration.md +5 -67
  41. package/docs/public-resource-protocol.en.md +8 -44
  42. package/docs/public-resource-protocol.md +8 -43
  43. package/docs/security-model.en.md +10 -132
  44. package/docs/security-model.md +10 -132
  45. package/docs/server-integration-contract.en.md +12 -813
  46. package/docs/server-integration-contract.md +12 -825
  47. package/examples/ecnu.enterprise-profile.example.json +16 -20
  48. package/examples/enterprise-profile.example.json +16 -20
  49. package/examples/identity-only.example.json +7 -2
  50. package/examples/litellm.enterprise-profile.example.json +10 -0
  51. package/examples/oidc-llm.enterprise-profile.example.json +13 -0
  52. package/examples/resources-discovery.example.json +7 -9
  53. package/lib/callback-page.js +5 -5
  54. package/lib/client.js +31 -48
  55. package/lib/desktop-oidc.js +7 -11
  56. package/lib/gateway-backend.js +443 -0
  57. package/lib/gateway-protocol.js +6 -0
  58. package/lib/index.js +14 -15
  59. package/lib/litellm-protocol.js +117 -0
  60. package/lib/model-resource-transport.js +19 -0
  61. package/lib/oidc-llm-protocol.js +87 -0
  62. package/lib/oidc.js +40 -338
  63. package/lib/profile.js +42 -74
  64. package/lib/provider/authorization-scope.js +46 -0
  65. package/lib/provider/core.js +5 -16
  66. package/lib/provider/index.js +11 -2
  67. package/lib/transport.js +5 -0
  68. package/lib/typert-schemas.js +1 -1
  69. package/package.json +2 -2
  70. package/protocol/openapi.yaml +33 -240
  71. package/protocol/resources.openapi.yaml +8 -9
  72. package/schema/enterprise-profile.v1alpha1.schema.json +508 -107
@@ -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,87 @@
1
+ import { serviceProtocolAllowed } from './transport.js'
2
+ // Experimental draft 0.1. These wire rules are not a published standard.
3
+ import { protocolError } from './litellm-protocol.js'
4
+
5
+ const fail = message => { throw protocolError('gateway_discovery_invalid', message) }
6
+ const tokenText = value => typeof value === 'string' && value.length > 0 && value.length <= 65536
7
+ const includes = (raw, key, values) => {
8
+ if (!Array.isArray(raw[key]) || values.some(value => !raw[key].includes(value))) fail(`oidc-llm discovery lacks required ${key}`)
9
+ }
10
+
11
+ function endpoint(value, profile, origin) {
12
+ let url
13
+ try { url = new URL(value) } catch { fail('oidc-llm metadata contains an invalid URL') }
14
+ if (typeof value !== 'string' || value.length > 2048 || !serviceProtocolAllowed(url, profile.allowInsecureDevelopment)
15
+ || url.username || url.password || url.search || url.hash || origin && url.origin !== origin) fail('oidc-llm URL is outside the configured trust boundary')
16
+ return url
17
+ }
18
+
19
+ export function detectOidcLlm(raw, profile) {
20
+ if (!profile.auth.experimentalOidcLlm) throw protocolError('gateway_protocol_unsupported', 'oidc-llm requires explicit experimental opt-in')
21
+ if (raw.contract_version !== undefined) fail('Ambiguous gateway discovery contracts')
22
+ const extension = raw.oidc_llm
23
+ if (!extension || Array.isArray(extension) || extension.version !== '0.1') throw protocolError('gateway_protocol_unsupported', 'Unsupported oidc-llm draft version')
24
+ const issuer = endpoint(raw.issuer, profile)
25
+ 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')
26
+ // An OIDC well-known document has an issuer-location relationship, even with a pin.
27
+ if (new URL(profile.auth.discoveryUrl).pathname.endsWith('/.well-known/openid-configuration')
28
+ && profile.auth.discoveryUrl !== raw.issuer.replace(/\/+$/, '') + '/.well-known/openid-configuration') fail('OIDC discovery location does not match issuer')
29
+ const resource = endpoint(extension.resource, profile)
30
+ const identityMode = profile.auth.identityMode
31
+ includes(extension, 'identity_modes_supported', ['oauth', identityMode])
32
+ includes(extension, 'client_registration_methods_supported', ['static'])
33
+ const scopes = identityMode === 'oidc'
34
+ ? ['openid', 'profile', 'offline_access', 'llm:models:read', 'llm:invoke']
35
+ : ['llm:profile', 'llm:models:read', 'llm:invoke']
36
+ for (const [key, values] of Object.entries({
37
+ response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'],
38
+ code_challenge_methods_supported: ['S256'], token_endpoint_auth_methods_supported: ['none'],
39
+ revocation_endpoint_auth_methods_supported: ['none'], scopes_supported: scopes,
40
+ })) includes(raw, key, values)
41
+ const result = {
42
+ protocol: 'oidc-llm-draft-0.1', issuer: raw.issuer, resource: extension.resource,
43
+ identityMode, clientId: profile.auth.clientId, scopes,
44
+ requireResponseIssuer: raw.authorization_response_iss_parameter_supported === true,
45
+ }
46
+ for (const [key, wire] of Object.entries({ authorizationEndpoint: 'authorization_endpoint', tokenEndpoint: 'token_endpoint', revocationEndpoint: 'revocation_endpoint' })) {
47
+ endpoint(raw[wire], profile)
48
+ result[key] = raw[wire]
49
+ }
50
+ endpoint(raw.userinfo_endpoint, profile, resource.origin)
51
+ endpoint(extension.api_base, profile, resource.origin)
52
+ result.userInfoEndpoint = raw.userinfo_endpoint
53
+ result.baseURL = extension.api_base.replace(/\/+$/, '')
54
+ if (identityMode === 'oidc') {
55
+ includes(raw, 'id_token_signing_alg_values_supported', ['RS256'])
56
+ if (!Array.isArray(raw.subject_types_supported) || !raw.subject_types_supported.some(v => ['public', 'pairwise'].includes(v))) fail('OIDC subject type is missing')
57
+ endpoint(raw.jwks_uri, profile)
58
+ result.jwksURI = raw.jwks_uri
59
+ }
60
+ return Object.freeze(result)
61
+ }
62
+
63
+ export function oidcLlmToken(raw, descriptor, now, previous) {
64
+ if (!tokenText(raw.access_token) || !tokenText(raw.refresh_token) || typeof raw.token_type !== 'string'
65
+ || raw.token_type.toLowerCase() !== 'bearer' || !Number.isSafeInteger(raw.expires_in)
66
+ || raw.expires_in <= 0 || raw.expires_in > 365 * 86400 || typeof raw.scope !== 'string') {
67
+ throw protocolError('gateway_token_invalid', 'oidc-llm token response is incomplete or invalid')
68
+ }
69
+ const scopes = raw.scope.split(' ')
70
+ const allowed = previous?.scopes ?? descriptor.scopes
71
+ if (scopes.some(scope => !scope || !allowed.includes(scope)) || new Set(scopes).size !== scopes.length
72
+ || descriptor.scopes.some(scope => !scopes.includes(scope))) {
73
+ throw protocolError('gateway_scope_changed', 'oidc-llm granted scopes do not match the requested authorization')
74
+ }
75
+ // No unapproved 15-minute ceiling: honor the actual advertised TTL.
76
+ return { accessToken: raw.access_token, refreshToken: raw.refresh_token, expiresAt: Math.floor(now() / 1000) + raw.expires_in, scopes, teamID: null }
77
+ }
78
+
79
+ export function oidcLlmIdentity(raw) {
80
+ if (typeof raw.sub !== 'string' || !/^[\x21-\x7e]{1,255}$/.test(raw.sub)) throw protocolError('oidc_userinfo_invalid', 'UserInfo has no valid subject')
81
+ for (const key of ['name', 'preferred_username', 'picture', 'email']) {
82
+ if (raw[key] !== undefined && typeof raw[key] !== 'string') throw protocolError('oidc_userinfo_invalid', 'UserInfo claim type is invalid')
83
+ }
84
+ if (raw.email_verified !== undefined && typeof raw.email_verified !== 'boolean') throw protocolError('oidc_userinfo_invalid', 'UserInfo claim type is invalid')
85
+ // Existing account UI consumes only the stable subject and display name.
86
+ return { sub: raw.sub, name: raw.name?.trim().slice(0, 256) || raw.preferred_username?.trim().slice(0, 256) || '已登录' }
87
+ }
package/lib/oidc.js CHANGED
@@ -1,6 +1,6 @@
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 { serviceProtocolAllowed } from './transport.js'
2
+ import { createHash, createPublicKey, randomBytes, timingSafeEqual, verify as verifySignature } from 'node:crypto'
3
+ import { emptyResources } from './resources.js'
4
4
 
5
5
  const FLOW_TTL_MS = 10 * 60 * 1000
6
6
  const MAX_PENDING_FLOWS = 32
@@ -44,10 +44,6 @@ async function responseJSON(response, stage) {
44
44
  return value
45
45
  }
46
46
 
47
- function isLoopback(hostname) {
48
- return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'
49
- }
50
-
51
47
  /** OIDC metadata endpoints may use origins distinct from the issuer over HTTPS. */
52
48
  function discoveredEndpoint(value, profile, label, optional = false) {
53
49
  if (optional && (value === undefined || value === '')) return ''
@@ -55,17 +51,8 @@ function discoveredEndpoint(value, profile, label, optional = false) {
55
51
  let endpoint
56
52
  try { endpoint = new URL(value) }
57
53
  catch (cause) { throw publicError('oidc_discovery_invalid', `OIDC ${label} is not an absolute URL`, cause) }
58
- const issuerURL = new URL(profile.oidc.issuer)
59
- const secure = endpoint.protocol === 'https:'
60
- const localDevelopment = issuerURL.protocol === 'http:' && isLoopback(issuerURL.hostname)
61
- && endpoint.protocol === 'http:' && isLoopback(endpoint.hostname)
62
- const explicitlyAllowedDevelopment = profile.allowInsecureDevelopment === true
63
- && typeof profile.insecureDevelopmentOrigin === 'string'
64
- && issuerURL.origin === profile.insecureDevelopmentOrigin
65
- && endpoint.protocol === 'http:'
66
- && endpoint.origin === profile.insecureDevelopmentOrigin
67
- if ((!secure && !localDevelopment && !explicitlyAllowedDevelopment) || endpoint.username || endpoint.password || endpoint.hash) {
68
- throw publicError('oidc_discovery_invalid', `OIDC ${label} must be HTTPS, loopback HTTP, or the profile's exact development HTTP origin`)
54
+ if (!serviceProtocolAllowed(endpoint, profile.allowInsecureDevelopment) || endpoint.username || endpoint.password || endpoint.hash) {
55
+ throw publicError('oidc_discovery_invalid', `OIDC ${label} must be HTTPS; set allowInsecureDevelopment=true to allow HTTP`)
69
56
  }
70
57
  return endpoint.toString()
71
58
  }
@@ -74,22 +61,6 @@ function sessionRef(profile) {
74
61
  return `DSH_OIDC_${profile.id.toUpperCase().replace(/-/g, '_')}_SESSION`
75
62
  }
76
63
 
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
64
  function nowSeconds(now) {
94
65
  return Math.floor(now() / 1000)
95
66
  }
@@ -103,11 +74,11 @@ function account(profile, session, credentialReady, credentialState = '') {
103
74
  profileID: profile.id,
104
75
  displayName: profile.displayName,
105
76
  organization: profile.organization,
106
- state: session ? (credentialReady || !profile.keyBinding ? 'connected' : 'authenticated') : 'signed_out',
77
+ state: session ? 'connected' : 'signed_out',
107
78
  ...(session?.identity?.name ? { userName: session.identity.name } : {}),
108
79
  ...(session?.identity?.affiliation ? { affiliation: session.identity.affiliation } : {}),
109
80
  ...(session?.expiresAt ? { accessExpiresAt: new Date(session.expiresAt * 1000).toISOString() } : {}),
110
- credentialRef: profile.keyBinding?.credentialRef ?? '',
81
+ credentialRef: '',
111
82
  credentialReady,
112
83
  ...(credentialState ? { credentialState } : {}),
113
84
  capabilities: Array.isArray(session?.capabilities) ? session.capabilities : [],
@@ -145,7 +116,7 @@ function runtimeProjection(runtime = {}, fallback = {}) {
145
116
  function profileManagement(profiles) {
146
117
  const rows = [...profiles.values()].map(profile => ({
147
118
  id: profile.id, displayName: profile.displayName, organization: profile.organization,
148
- baseURL: profile.provider?.baseURL ?? profile.oidc.issuer, builtIn: true, configured: true, enabled: true,
119
+ baseURL: profile.provider?.baseURL || profile.auth?.discoveryUrl || profile.oidc.issuer, builtIn: true, configured: true, enabled: true,
149
120
  providerID: profile.provider?.id ?? '',
150
121
  runtime: runtimeProjection({ ...profile.provider, modelSource: profile.provider?.modelSource ?? 'none' }, profile.provider),
151
122
  }))
@@ -269,6 +240,7 @@ export class WebOidcBackend {
269
240
  }
270
241
  const result = Object.freeze({
271
242
  issuer: raw.issuer,
243
+ requireResponseIssuer: raw.authorization_response_iss_parameter_supported === true,
272
244
  authorizationEndpoint: discoveredEndpoint(raw.authorization_endpoint, profile, 'authorization_endpoint'),
273
245
  tokenEndpoint: discoveredEndpoint(raw.token_endpoint, profile, 'token_endpoint'),
274
246
  userInfoEndpoint: discoveredEndpoint(raw.userinfo_endpoint, profile, 'userinfo_endpoint'),
@@ -284,7 +256,7 @@ export class WebOidcBackend {
284
256
  const discovery = await this.discover(profile)
285
257
  this.pruneFlows()
286
258
  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)
259
+ const { authorizationURL } = await this.createAuthorization(profile, discovery, this.redirectURI)
288
260
  return { mode: 'redirect', authorizationURL }
289
261
  }
290
262
 
@@ -323,9 +295,9 @@ export class WebOidcBackend {
323
295
  const profile = this.profile(flow.profileID)
324
296
  const session = await this.exchangeAuthorization(requested, flow)
325
297
  await this.saveSession(profile, session, flow.epoch)
326
- const status = await this.reconcile(profile.id, { allowProvision: false })
298
+ const status = await this.reconcile(profile.id)
327
299
  this.accountChanged(status)
328
- outcome = status.state === 'connected' ? 'connected' : 'credential-required'
300
+ outcome = 'connected'
329
301
  } catch (cause) {
330
302
  this.ctx.logger.warn('OIDC callback failed', { code: cause?.code ?? 'oidc_callback_failed' })
331
303
  outcome = cause?.code ?? 'oidc_callback_failed'
@@ -339,11 +311,14 @@ export class WebOidcBackend {
339
311
 
340
312
  async exchangeAuthorization(requested, flow) {
341
313
  const profile = this.profile(flow.profileID)
314
+ const discovery = await this.discover(profile)
315
+ const issuers = requested.searchParams.getAll('iss')
316
+ if ((!issuers.length && discovery.requireResponseIssuer) || issuers.length > 1
317
+ || (issuers.length === 1 && !equalText(issuers[0], discovery.issuer))) {
318
+ throw publicError('oidc_callback_invalid', 'OIDC authorization response issuer is missing or invalid')
319
+ }
342
320
  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)
321
+ return this.exchangeCode(profile, discovery, singleQueryParameter(requested, 'code', true), flow.verifier, flow.nonce, flow.redirectURI)
347
322
  }
348
323
 
349
324
  async exchangeCode(profile, discovery, code, verifier, nonce, redirectURI = this.redirectURI) {
@@ -381,13 +356,14 @@ export class WebOidcBackend {
381
356
  }
382
357
  }
383
358
 
384
- async verifyIDToken(profile, discovery, raw, expectedNonce, accessToken) {
359
+ async verifyIDToken(profile, discovery, raw, expectedNonce, accessToken, { refresh = false } = {}) {
360
+ if (typeof raw !== 'string') throw publicError('oidc_id_token_invalid', 'OIDC ID Token is missing')
385
361
  const parts = raw.split('.')
386
362
  if (parts.length !== 3) throw publicError('oidc_id_token_invalid', 'OIDC ID Token is malformed')
387
363
  const header = decodePart(parts[0], 'ID Token header')
388
364
  const claims = decodePart(parts[1], 'ID Token claims')
389
365
  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) })
366
+ const keysResponse = await this.fetch(discovery.jwksURI, { headers: { accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(20_000) })
391
367
  const jwks = await responseJSON(keysResponse, 'oidc_jwks_failed')
392
368
  const candidates = Array.isArray(jwks.keys) ? jwks.keys.filter(key => (
393
369
  key.kid === header.kid && key.kty === 'RSA' && (key.use === undefined || key.use === 'sig')
@@ -402,8 +378,12 @@ export class WebOidcBackend {
402
378
  const now = nowSeconds(this.now)
403
379
  const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud]
404
380
  const audienceValid = audience.includes(profile.oidc.clientId)
381
+ && audience.every(value => typeof value === 'string' && value !== '')
382
+ && new Set(audience).size === audience.length
405
383
  && (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)) {
384
+ && (claims.azp === undefined || claims.azp === profile.oidc.clientId)
385
+ const nonceValid = refresh && claims.nonce === undefined || equalText(claims.nonce, expectedNonce)
386
+ if (claims.iss !== profile.oidc.issuer || !audienceValid || typeof claims.sub !== 'string' || claims.sub === '' || !nonceValid) {
407
387
  throw publicError('oidc_id_token_invalid', 'OIDC ID Token identity claims are invalid')
408
388
  }
409
389
  if (!Number.isFinite(claims.exp) || !Number.isFinite(claims.iat)
@@ -435,93 +415,21 @@ export class WebOidcBackend {
435
415
  try { session = JSON.parse(record.value) } catch { await this.ctx.credentials.unset(sessionRef(profile)); return undefined }
436
416
  if (session?.issuer !== profile.oidc.issuer || session?.clientId !== profile.oidc.clientId
437
417
  || typeof session?.accessToken !== 'string' || !Number.isFinite(session?.expiresAt)
438
- || typeof session?.identity?.sub !== 'string') {
439
- await this.clearOwnedCredential(profile, session)
418
+ || typeof session?.identity?.sub !== 'string' || session.resourceBinding !== undefined
419
+ || session.runtimeCredentialHash !== undefined) {
440
420
  await this.ctx.credentials.unset(sessionRef(profile))
441
421
  return undefined
442
422
  }
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
423
  return session
474
424
  })
475
425
  }
476
426
 
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
427
  accountEpoch(profile) { return this.accountEpochs.get(profile.id) ?? 0 }
520
428
 
521
429
  writeCredentials(profile, epoch, operation) {
522
430
  // Serialize vault writes so logout clears even a write already in progress.
523
431
  // Network requests do not hold this queue; late replies cannot restore keys.
524
- const queue = profile.keyBinding?.credentialRef ?? sessionRef(profile)
432
+ const queue = sessionRef(profile)
525
433
  const write = (this.credentialWrites.get(queue) ?? Promise.resolve()).catch(() => {}).then(() => {
526
434
  if (epoch !== this.accountEpoch(profile)) throw publicError('oidc_login_cancelled', '账户状态已改变,请重新登录。')
527
435
  return operation()
@@ -532,18 +440,11 @@ export class WebOidcBackend {
532
440
  }
533
441
 
534
442
  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
- })
443
+ return this.writeCredentials(profile, epoch, () => this.ctx.credentials.unset(sessionRef(profile)))
542
444
  }
543
445
 
544
446
  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) })))
447
+ return this.writeCredentials(profile, epoch, () => this.ctx.credentials.set(sessionRef(profile), JSON.stringify(session)))
547
448
  }
548
449
 
549
450
  refresh(profile, session) {
@@ -610,12 +511,8 @@ export class WebOidcBackend {
610
511
  const profile = this.profile(profileID)
611
512
  const epoch = this.accountEpoch(profile)
612
513
  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
514
  if (epoch !== this.accountEpoch(profile)) return account(profile, undefined, false)
618
- return account(profile, session, typeof credential?.value === 'string' && credential.value !== '')
515
+ return account(profile, session, false, 'not_required')
619
516
  }
620
517
 
621
518
  async authorized(profile, session, endpoint, init = {}) {
@@ -639,7 +536,6 @@ export class WebOidcBackend {
639
536
  const target = new URL(endpoint)
640
537
  const allowed = new Set([
641
538
  new URL(profile.oidc.issuer).origin,
642
- ...(profile.keyBinding ? [new URL(profile.keyBinding.baseURL).origin] : []),
643
539
  ...this.authorizedOrigins,
644
540
  ])
645
541
  if (!allowed.has(target.origin) || target.username || target.password || target.hash) {
@@ -650,87 +546,15 @@ export class WebOidcBackend {
650
546
  return (await this.authorized(profile, session, target.toString(), init)).response
651
547
  }
652
548
 
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 })
549
+ async modelResourceFetch(profileID) {
550
+ this.profile(profileID)
551
+ throw publicError('oidc_resource_not_configured', 'Identity-only sign-in has no model resources; configure gateway authorization')
678
552
  }
679
553
 
680
- async reconcile(profileID, options = {}) {
554
+ async reconcile(profileID) {
681
555
  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')
556
+ const session = await this.activeSession(profile)
557
+ return account(profile, session, false, 'not_required')
734
558
  }
735
559
 
736
560
  resources(profileID) {
@@ -742,32 +566,7 @@ export class WebOidcBackend {
742
566
  }
743
567
 
744
568
  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
569
+ return emptyResources(this.profile(profileID))
771
570
  }
772
571
 
773
572
  management() { return Promise.resolve(profileManagement(this.profiles)) }
@@ -805,101 +604,4 @@ export class WebOidcBackend {
805
604
  }
806
605
  }
807
606
 
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
607
  export { account, publicError, sessionRef }