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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.en.md +6 -0
  2. package/CHANGELOG.md +6 -0
  3. package/README.md +9 -1
  4. package/README_EN.md +9 -1
  5. package/THIRD_PARTY_NOTICES.en.md +5 -4
  6. package/THIRD_PARTY_NOTICES.md +5 -5
  7. package/docs/account-extensions.en.md +7 -1
  8. package/docs/account-extensions.md +7 -1
  9. package/docs/compatibility.en.md +3 -1
  10. package/docs/compatibility.md +3 -1
  11. package/docs/development.en.md +1 -1
  12. package/docs/development.md +1 -1
  13. package/docs/dsh-integration.en.md +3 -3
  14. package/docs/dsh-integration.md +3 -3
  15. package/docs/enterprise-profile.en.md +47 -0
  16. package/docs/enterprise-profile.md +47 -0
  17. package/docs/gateway-auth/README.md +6 -5
  18. package/docs/gateway-auth/README_EN.md +6 -5
  19. package/docs/gateway-auth/experimental-oidc-llm.en.md +9 -7
  20. package/docs/gateway-auth/experimental-oidc-llm.md +8 -6
  21. package/docs/gateway-auth/litellm-setup.en.md +9 -3
  22. package/docs/gateway-auth/litellm-setup.md +9 -3
  23. package/docs/gateway-auth/oidc-llm-draft.en.md +89 -63
  24. package/docs/gateway-auth/oidc-llm-draft.md +86 -60
  25. package/docs/getting-started.en.md +3 -1
  26. package/docs/getting-started.md +3 -1
  27. package/docs/key-binding-protocol.en.md +1 -1
  28. package/docs/key-binding-protocol.md +1 -1
  29. package/docs/oidc-interoperability.en.md +1 -1
  30. package/docs/oidc-interoperability.md +1 -1
  31. package/docs/open-integration.en.md +3 -1
  32. package/docs/open-integration.md +3 -1
  33. package/examples/ecnu.enterprise-profile.example.json +2 -0
  34. package/examples/enterprise-profile.example.json +2 -0
  35. package/examples/litellm.enterprise-profile.example.json +9 -1
  36. package/examples/oidc-llm.enterprise-profile.example.json +33 -1
  37. package/examples/resources-discovery.example.json +7 -1
  38. package/lib/callback-page.js +9 -4
  39. package/lib/client.js +460 -360
  40. package/lib/desktop-oidc.js +2 -2
  41. package/lib/gateway-backend.js +20 -5
  42. package/lib/index.js +8 -6
  43. package/lib/litellm-protocol.js +2 -1
  44. package/lib/model-types.js +7 -0
  45. package/lib/oidc-llm-protocol.js +6 -2
  46. package/lib/oidc.js +19 -13
  47. package/lib/profile.js +24 -9
  48. package/lib/provider/authorization-scope.js +4 -0
  49. package/lib/provider/core.js +3 -3
  50. package/lib/provider/index.js +18 -2
  51. package/lib/provider/transform-adapter.js +4 -0
  52. package/lib/resources.js +12 -4
  53. package/lib/token-lifetime.js +15 -0
  54. package/lib/typert-schemas.js +14 -12
  55. package/package.json +104 -97
  56. package/protocol/resources.openapi.yaml +11 -3
  57. package/schema/enterprise-profile.v1alpha1.schema.json +36 -0
@@ -6,7 +6,7 @@ import { callbackLanguage, callbackPage } from './callback-page.js'
6
6
  const MAX_FLOWS = 32
7
7
  const MAX_HISTORY = 128
8
8
  const error = (code, message) => Object.assign(new Error(message), { code })
9
- const safeCodes = new Set(['oidc_callback_invalid', 'oidc_authorization_rejected', 'oidc_token_invalid', 'oidc_id_token_invalid', 'oidc_userinfo_invalid', 'gateway_token_invalid', 'gateway_scope_changed', 'gateway_identity_changed', 'gateway_callback_issuer_missing', 'gateway_callback_issuer_invalid'])
9
+ const safeCodes = new Set(['oidc_id_token_time_invalid', 'oidc_callback_invalid', 'oidc_authorization_rejected', 'oidc_token_invalid', 'oidc_id_token_invalid', 'oidc_userinfo_invalid', 'gateway_token_invalid', 'gateway_scope_changed', 'gateway_identity_changed', 'gateway_callback_issuer_missing', 'gateway_callback_issuer_invalid'])
10
10
 
11
11
  function reply(response, status, profile, outcome, language) {
12
12
  const page = callbackPage(profile, outcome, language)
@@ -138,7 +138,7 @@ export class DesktopOidcBackend extends WebOidcBackend {
138
138
  this.ctx.logger.warn('Desktop OIDC host credentials are unavailable')
139
139
  })
140
140
  await attempt.processing
141
- const failedOutcome = ['gateway_callback_issuer_missing', 'gateway_callback_issuer_invalid'].includes(attempt.errorCode) ? 'issuer-invalid' : 'failed'
141
+ const failedOutcome = ['gateway_callback_issuer_missing', 'gateway_callback_issuer_invalid'].includes(attempt.errorCode) ? 'issuer-invalid' : attempt.errorCode === 'oidc_id_token_time_invalid' ? 'time-invalid' : 'failed'
142
142
  if (!response.destroyed) reply(response, attempt.state === 'completed' ? 200 : 400, profile,
143
143
  attempt.state === 'completed' ? 'completed' : failedOutcome, language)
144
144
  await this.closeCallback(attempt)
@@ -6,6 +6,7 @@ import { gatewayJSON, liteLLMToken, protocolError, readGatewayJSON, registerLite
6
6
  import { detectGatewayProtocol } from './gateway-protocol.js'
7
7
  import { oidcLlmIdentity, oidcLlmToken } from './oidc-llm-protocol.js'
8
8
  import { bufferResourceResponse, validateResourceRead } from './model-resource-transport.js'
9
+ import { accessTokenNeedsRefresh } from './token-lifetime.js'
9
10
 
10
11
  const seconds = backend => Math.floor(backend.now() / 1000)
11
12
  const fingerprint = (profile, descriptor) => createHash('sha256').update(JSON.stringify({ auth: profile.auth, descriptor })).digest('hex')
@@ -266,7 +267,7 @@ function withGatewayAuth(Base) {
266
267
  const epoch = this.accountEpoch(profile)
267
268
  const session = await this.loadSession(profile)
268
269
  if (!session) return undefined
269
- if (session.expiresAt > seconds(this) + 90) return session
270
+ if (!accessTokenNeedsRefresh(session, this.now)) return session
270
271
  try { return await this.refresh(profile, session) }
271
272
  catch (cause) {
272
273
  // Brief outages must not discard a still-valid access token; expired tokens stop.
@@ -314,14 +315,26 @@ function withGatewayAuth(Base) {
314
315
  return account(profile, session, true, 'access_token')
315
316
  }
316
317
 
317
- async authorizedFetch(profileID, endpoint, init = {}) {
318
+ async authorizedFetch(profileID, endpoint, init = {}, authorization = {}) {
319
+ const retryUnauthorized = authorization.retryUnauthorized === true
320
+ if (retryUnauthorized && init.body != null && typeof init.body !== 'string') throw new TypeError('Authorization retry requires a buffered text request body')
318
321
  const profile = this.profile(profileID)
319
322
  if (!profile.auth) return super.authorizedFetch(profileID, endpoint, init)
320
323
  const epoch = this.accountEpoch(profile), descriptor = await this.discover(profile)
321
324
  const target = new URL(endpoint)
322
325
  // Host-only model and own-account routes, never key administration or arbitrary origins.
323
326
  const modelBase = new URL(`${descriptor.baseURL}/`)
324
- if (target.username || target.password || target.hash || target.origin !== modelBase.origin || !(target.href === descriptor.userInfoEndpoint || target.pathname.startsWith(modelBase.pathname))) throw protocolError('oidc_authorized_origin_denied', 'Gateway token destination is not an authorized resource')
327
+ // Trusted Host services can grant one exact issuer POST route for their
328
+ // own request. This option is never accepted through a renderer RPC or
329
+ // persisted as a broader model-resource allowlist.
330
+ const servicePath = authorization.issuerServicePath
331
+ const issuerService = typeof servicePath === 'string'
332
+ && /^\/(?:[a-zA-Z0-9_-]+\/)*[a-zA-Z0-9_-]+$/.test(servicePath)
333
+ && (init.method ?? 'GET').toUpperCase() === 'POST'
334
+ && target.href === new URL(descriptor.issuer).origin + servicePath
335
+ const modelResource = target.origin === modelBase.origin
336
+ && (target.href === descriptor.userInfoEndpoint || target.pathname.startsWith(modelBase.pathname))
337
+ if (target.username || target.password || target.hash || !(modelResource || issuerService)) throw protocolError('oidc_authorized_origin_denied', 'Gateway token destination is not an authorized resource')
325
338
  const scope = await this.createGatewayScope(profileID)
326
339
  const lease = scope.open(init.signal ?? AbortSignal.timeout(20_000))
327
340
  let response
@@ -331,8 +344,9 @@ function withGatewayAuth(Base) {
331
344
  if (!session) throw protocolError('oidc_login_required', 'Gateway sign-in is required')
332
345
  const request = current => this.fetch(target.href, { ...init, headers: { ...Object.fromEntries(new Headers(init.headers)), authorization: `Bearer ${current.accessToken}` }, redirect: 'error', signal: lease.signal })
333
346
  response = await request(session)
334
- // Retry only safe reads. A generation (including SSE) is never replayed here.
335
- if (response.status === 401 && ['GET', 'HEAD'].includes((init.method ?? 'GET').toUpperCase())) {
347
+ // Reads recover automatically. Trusted Host services may explicitly opt
348
+ // in for replayable calls; model generation does not opt in.
349
+ if (response.status === 401 && (retryUnauthorized || ['GET', 'HEAD'].includes((init.method ?? 'GET').toUpperCase()))) {
336
350
  await response.body?.cancel()
337
351
  session = await this.refresh(profile, session)
338
352
  lease.assertCurrent()
@@ -379,6 +393,7 @@ function withGatewayAuth(Base) {
379
393
  this.updateProvider(next)
380
394
  this.profiles.set(profileID, next)
381
395
  result.models = models
396
+ if (models.some(model => model.type === 'unknown')) result.issues.push('model_types_unresolved')
382
397
  } catch (cause) {
383
398
  if (cause.code === 'oidc_login_cancelled' || cause.code === 'oidc_login_required') throw cause
384
399
  result.issues.push('models_unavailable')
package/lib/index.js CHANGED
@@ -45,7 +45,7 @@ export class OidcAccountService extends TypertRemoteService {
45
45
  })
46
46
  const backendOptions = {
47
47
  authorizedOrigins: config.authorizedOrigins,
48
- accountChanged: status => this.notifyAccount(status),
48
+ accountChanged: status => this.notifyAccount(status, { force: true }),
49
49
  updateProvider: profile => {
50
50
  const profiles = new Map(this.profiles)
51
51
  profiles.set(profile.id, profile)
@@ -98,18 +98,20 @@ export class OidcAccountService extends TypertRemoteService {
98
98
  return { opened: true }
99
99
  }
100
100
 
101
- notifyAccount(status) {
102
- if (this.accountStates.get(status.profileID) !== status.state) {
101
+ notifyAccount(status, { force = false } = {}) {
102
+ // A completed sign-in replaces authorization even when both sessions are
103
+ // "connected". Consumers must reset work blocked by the previous session.
104
+ if (force || this.accountStates.get(status.profileID) !== status.state) {
103
105
  this.accountStates.set(status.profileID, status.state)
104
- this.ctx.emit('oidc/accounts-changed', { profileID: status.profileID, state: status.state })
106
+ this.ctx.emit('oidc/accounts-changed', { profileID: status.profileID, state: status.state, ...(force ? { authorizationChanged: true } : {}) })
105
107
  }
106
108
  return status
107
109
  }
108
110
  async status(profileID) { return this.notifyAccount(await this.callBackend('status', profileID)) }
109
111
  resources(profileID) { return this.callBackend('resources', profileID) }
110
112
  // Host-only capability: deliberately absent from Remote markers and Typert.
111
- authorizedFetch(profileID, endpoint, init) {
112
- return this.callBackend('authorizedFetch', profileID, endpoint, init)
113
+ authorizedFetch(profileID, endpoint, init, authorization) {
114
+ return this.callBackend('authorizedFetch', profileID, endpoint, init, authorization)
113
115
  }
114
116
  modelResourceFetch(profileID, relativePath, options) {
115
117
  return this.callBackend('modelResourceFetch', profileID, relativePath, options)
@@ -1,4 +1,5 @@
1
1
  import { serviceProtocolAllowed } from './transport.js'
2
+ import { accessTokenTiming } from './token-lifetime.js'
2
3
  // LiteLLM v1.101.0, native CLI auth contract 1. This is OAuth, not OIDC.
3
4
  // Protocol-specific wire fields stay here; tokens never cross the renderer RPC.
4
5
  const MAX_BYTES = 1024 * 1024
@@ -99,7 +100,7 @@ export function liteLLMToken(raw, now, previous) {
99
100
  throw protocolError('gateway_token_invalid', 'LiteLLM token response is incomplete or invalid')
100
101
  }
101
102
  if (previous && (raw.user_id !== previous.identity.sub || raw.team_id !== previous.teamID)) throw protocolError('gateway_identity_changed', 'Gateway authorization identity changed during refresh; sign in again')
102
- return { accessToken: raw.access_token, refreshToken: raw.refresh_token, expiresAt: Math.floor(now() / 1000) + raw.expires_in, teamID: raw.team_id, identity: { sub: raw.user_id, name: previous?.identity?.name || raw.user_id } }
103
+ return { accessToken: raw.access_token, refreshToken: raw.refresh_token, ...accessTokenTiming(raw.expires_in, now), teamID: raw.team_id, identity: { sub: raw.user_id, name: previous?.identity?.name || raw.user_id } }
103
104
  }
104
105
 
105
106
  export function tokenRequest(fetcher, descriptor, values) {
@@ -0,0 +1,7 @@
1
+ // Purpose is independent of input modalities: a vision LLM is still an LLM.
2
+ // Unknown (including future server types) is never registered for conversation.
3
+ export const MODEL_TYPES = Object.freeze(['llm', 'embedding', 'rerank', 'image', 'tts', 'unknown'])
4
+
5
+ export function isLLM(model) {
6
+ return model.type === 'llm'
7
+ }
@@ -1,4 +1,5 @@
1
1
  import { serviceProtocolAllowed } from './transport.js'
2
+ import { accessTokenTiming } from './token-lifetime.js'
2
3
  // Experimental draft 0.1. These wire rules are not a published standard.
3
4
  import { protocolError } from './litellm-protocol.js'
4
5
 
@@ -30,9 +31,12 @@ export function detectOidcLlm(raw, profile) {
30
31
  const identityMode = profile.auth.identityMode
31
32
  includes(extension, 'identity_modes_supported', ['oauth', identityMode])
32
33
  includes(extension, 'client_registration_methods_supported', ['static'])
33
- const scopes = identityMode === 'oidc'
34
+ const identityScopes = identityMode === 'oidc'
34
35
  ? ['openid', 'profile', 'offline_access', 'llm:models:read', 'llm:invoke']
35
36
  : ['llm:profile', 'llm:models:read', 'llm:invoke']
37
+ // Only request explicitly configured services; discovery is not consent to
38
+ // every capability advertised by the authorization server.
39
+ const scopes = [...new Set([...identityScopes, ...(profile.auth.additionalScopes ?? [])])]
36
40
  for (const [key, values] of Object.entries({
37
41
  response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'],
38
42
  code_challenge_methods_supported: ['S256'], token_endpoint_auth_methods_supported: ['none'],
@@ -73,7 +77,7 @@ export function oidcLlmToken(raw, descriptor, now, previous) {
73
77
  throw protocolError('gateway_scope_changed', 'oidc-llm granted scopes do not match the requested authorization')
74
78
  }
75
79
  // 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 }
80
+ return { accessToken: raw.access_token, refreshToken: raw.refresh_token, ...accessTokenTiming(raw.expires_in, now), scopes, teamID: null }
77
81
  }
78
82
 
79
83
  export function oidcLlmIdentity(raw) {
package/lib/oidc.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { serviceProtocolAllowed } from './transport.js'
2
2
  import { createHash, createPublicKey, randomBytes, timingSafeEqual, verify as verifySignature } from 'node:crypto'
3
3
  import { emptyResources } from './resources.js'
4
+ import { accessTokenNeedsRefresh, accessTokenTiming } from './token-lifetime.js'
4
5
 
5
6
  const FLOW_TTL_MS = 10 * 60 * 1000
6
7
  const MAX_PENDING_FLOWS = 32
7
8
  const CLOCK_SKEW_SECONDS = 60
8
- const ACCESS_REFRESH_SECONDS = 90
9
9
  const MAX_RESPONSE_BYTES = 1024 * 1024
10
10
  export const OIDC_CALLBACK_PATH = '/oauth/callback'
11
11
 
@@ -225,7 +225,7 @@ export class WebOidcBackend {
225
225
  let response
226
226
  try {
227
227
  response = await this.fetch(discoveryURL(profile.oidc.issuer), {
228
- headers: { accept: 'application/json' }, signal: AbortSignal.timeout(20_000),
228
+ headers: { accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(20_000),
229
229
  })
230
230
  } catch (cause) {
231
231
  throw publicError('oidc_discovery_failed', 'unable to read OIDC discovery metadata', cause)
@@ -328,7 +328,7 @@ export class WebOidcBackend {
328
328
  })
329
329
  const response = await this.fetch(discovery.tokenEndpoint, {
330
330
  method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
331
- body, signal: AbortSignal.timeout(20_000),
331
+ body, redirect: 'error', signal: AbortSignal.timeout(20_000),
332
332
  })
333
333
  const token = await responseJSON(response, 'oidc_token_request_failed')
334
334
  if (typeof token.access_token !== 'string' || typeof token.id_token !== 'string' || token.token_type?.toLowerCase() !== 'bearer') {
@@ -336,6 +336,7 @@ export class WebOidcBackend {
336
336
  }
337
337
  const expiresIn = token.expires_in === undefined ? 3600 : Number(token.expires_in)
338
338
  if (!Number.isFinite(expiresIn) || expiresIn <= 0) throw publicError('oidc_token_invalid', 'OIDC token expiry is invalid')
339
+ const timing = accessTokenTiming(expiresIn, this.now)
339
340
  const claims = await this.verifyIDToken(profile, discovery, token.id_token, nonce, token.access_token)
340
341
  const userInfo = await this.userInfo(discovery.userInfoEndpoint, token.access_token)
341
342
  if (!equalText(userInfo.sub, claims.sub)) throw publicError('oidc_userinfo_invalid', 'OIDC UserInfo subject does not match ID Token')
@@ -346,7 +347,7 @@ export class WebOidcBackend {
346
347
  clientId: profile.oidc.clientId,
347
348
  accessToken: token.access_token,
348
349
  refreshToken: typeof token.refresh_token === 'string' ? token.refresh_token : '',
349
- expiresAt: nowSeconds(this.now) + Math.max(60, expiresIn),
350
+ ...timing,
350
351
  identity: {
351
352
  sub: claims.sub,
352
353
  name: userName,
@@ -387,9 +388,12 @@ export class WebOidcBackend {
387
388
  throw publicError('oidc_id_token_invalid', 'OIDC ID Token identity claims are invalid')
388
389
  }
389
390
  if (!Number.isFinite(claims.exp) || !Number.isFinite(claims.iat)
390
- || claims.exp < now - CLOCK_SKEW_SECONDS || claims.iat > now + CLOCK_SKEW_SECONDS
391
- || (claims.nbf !== undefined && (!Number.isFinite(claims.nbf) || claims.nbf > now + CLOCK_SKEW_SECONDS))) {
392
- throw publicError('oidc_id_token_invalid', 'OIDC ID Token time claims are invalid')
391
+ || (claims.nbf !== undefined && !Number.isFinite(claims.nbf))) {
392
+ throw publicError('oidc_id_token_invalid', 'OIDC ID Token time claims are malformed')
393
+ }
394
+ if (claims.exp < now - CLOCK_SKEW_SECONDS || claims.iat > now + CLOCK_SKEW_SECONDS
395
+ || (claims.nbf !== undefined && claims.nbf > now + CLOCK_SKEW_SECONDS)) {
396
+ throw publicError('oidc_id_token_time_invalid', 'OIDC ID Token time claims are invalid')
393
397
  }
394
398
  if (claims.at_hash !== undefined && (typeof claims.at_hash !== 'string' || !verifyAccessTokenHash(accessToken, claims.at_hash))) {
395
399
  throw publicError('oidc_id_token_invalid', 'OIDC ID Token access-token hash is invalid')
@@ -399,7 +403,7 @@ export class WebOidcBackend {
399
403
 
400
404
  async userInfo(endpoint, accessToken) {
401
405
  const response = await this.fetch(endpoint, {
402
- headers: { authorization: `Bearer ${accessToken}`, accept: 'application/json' }, signal: AbortSignal.timeout(20_000),
406
+ headers: { authorization: `Bearer ${accessToken}`, accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(20_000),
403
407
  })
404
408
  const result = await responseJSON(response, 'oidc_userinfo_failed')
405
409
  if (typeof result.sub !== 'string' || result.sub === '') throw publicError('oidc_userinfo_invalid', 'OIDC UserInfo has no subject')
@@ -459,13 +463,14 @@ export class WebOidcBackend {
459
463
  const epoch = this.accountEpoch(profile)
460
464
  const saved = await this.loadSession(profile)
461
465
  if (!saved) throw publicError('oidc_login_required', 'OIDC session is no longer available')
462
- if (saved && saved.accessToken !== session.accessToken && saved.expiresAt > nowSeconds(this.now) + ACCESS_REFRESH_SECONDS) return saved
466
+ if (saved.accessToken !== session.accessToken && saved.expiresAt > nowSeconds(this.now)) return saved
467
+ session = saved
463
468
  if (!session.refreshToken) throw publicError('oidc_login_required', 'OIDC session cannot be refreshed')
464
469
  const discovery = await this.discover(profile)
465
470
  const response = await this.fetch(discovery.tokenEndpoint, {
466
471
  method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
467
472
  body: new URLSearchParams({ grant_type: 'refresh_token', client_id: profile.oidc.clientId, refresh_token: session.refreshToken }),
468
- signal: AbortSignal.timeout(20_000),
473
+ redirect: 'error', signal: AbortSignal.timeout(20_000),
469
474
  })
470
475
  let token
471
476
  try { token = await responseJSON(response, 'oidc_refresh_failed') }
@@ -486,7 +491,7 @@ export class WebOidcBackend {
486
491
  ...session,
487
492
  accessToken: token.access_token,
488
493
  refreshToken: typeof token.refresh_token === 'string' ? token.refresh_token : session.refreshToken,
489
- expiresAt: nowSeconds(this.now) + Math.max(60, expiresIn),
494
+ ...accessTokenTiming(expiresIn, this.now),
490
495
  }
491
496
  await this.saveSession(profile, next, epoch)
492
497
  return next
@@ -496,8 +501,9 @@ export class WebOidcBackend {
496
501
  const epoch = this.accountEpoch(profile)
497
502
  let session = await this.loadSession(profile)
498
503
  if (!session) return undefined
499
- if (session.expiresAt <= nowSeconds(this.now) + ACCESS_REFRESH_SECONDS) {
504
+ if (accessTokenNeedsRefresh(session, this.now)) {
500
505
  if (!session.refreshToken) {
506
+ if (session.expiresAt > nowSeconds(this.now)) return session
501
507
  await this.clearCredentials(profile, epoch)
502
508
  this.accountChanged(account(profile, undefined, false))
503
509
  return undefined
@@ -595,7 +601,7 @@ export class WebOidcBackend {
595
601
  const token = session.refreshToken || session.accessToken
596
602
  await this.fetch(discovery.revocationEndpoint, {
597
603
  method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
598
- body: new URLSearchParams({ token, client_id: profile.oidc.clientId }), signal: AbortSignal.timeout(10_000),
604
+ body: new URLSearchParams({ token, client_id: profile.oidc.clientId }), redirect: 'error', signal: AbortSignal.timeout(10_000),
599
605
  })
600
606
  }
601
607
  } catch (cause) { this.ctx.logger.warn(cause instanceof Error ? cause : new Error(String(cause))) }
package/lib/profile.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { serviceProtocolAllowed } from './transport.js'
2
+ import { MODEL_TYPES, isLLM } from './model-types.js'
2
3
  import { readFileSync } from 'node:fs'
3
4
 
4
5
  export const PROFILE_SCHEMA_VERSION = 'dsh-oidc/v1alpha1'
@@ -19,7 +20,7 @@ const allowedRootKeys = new Set([
19
20
  const allowedOidcKeys = new Set(['issuer', 'clientId', 'scopes'])
20
21
  const allowedBrandKeys = new Set([
21
22
  'productName', 'organizationName', 'mark', 'logoURL', 'primaryColor',
22
- 'loginTitle', 'loginDescription', 'supportURL',
23
+ 'loginTitle', 'loginDescription', 'loginButtonLabel', 'supportURL', 'language',
23
24
  ])
24
25
  const allowedProviderKeys = new Set([
25
26
  'id', 'displayName', 'adapter', 'baseURL', 'reasoning', 'defaultContextWindow',
@@ -27,7 +28,7 @@ const allowedProviderKeys = new Set([
27
28
  'requestImageMaxBytes', 'streamIdleTimeoutMs', 'retryPolicy', 'compat', 'models', 'modelSource',
28
29
  ])
29
30
  const allowedModelKeys = new Set([
30
- 'id', 'name', 'input', 'contextWindow', 'maxTokens', 'reasoning',
31
+ 'id', 'type', 'name', 'input', 'contextWindow', 'maxTokens', 'reasoning',
31
32
  'reasoningEfforts', 'defaultReasoningEffort', 'compat',
32
33
  ])
33
34
  const allowedCompatKeys = new Set([
@@ -114,10 +115,14 @@ function normalizeBrand(value = {}) {
114
115
  }
115
116
  result.logoURL = logo.startsWith('https://') ? safeHTTPSResource(logo, 'brand.logoURL', 128 * 1024) : logo
116
117
  }
117
- for (const key of ['loginTitle', 'loginDescription']) {
118
- if (source[key] !== undefined) result[key] = text(source[key], `brand.${key}`, key === 'loginTitle' ? 120 : 500)
118
+ for (const [key, max] of Object.entries({ loginTitle: 120, loginDescription: 500, loginButtonLabel: 40 })) {
119
+ if (source[key] !== undefined) result[key] = text(source[key], `brand.${key}`, max)
119
120
  }
120
121
  if (source.supportURL !== undefined) result.supportURL = exactURL(source.supportURL, 'brand.supportURL')
122
+ if (source.language !== undefined) {
123
+ if (!['auto', 'zh-CN', 'en'].includes(source.language)) throw new Error('brand.language must be auto, zh-CN or en')
124
+ result.language = source.language
125
+ }
121
126
  return Object.freeze(result)
122
127
  }
123
128
 
@@ -162,6 +167,8 @@ export function normalizeModels(models, providerID) {
162
167
  const model = object(raw, label)
163
168
  exactKeys(model, allowedModelKeys, label)
164
169
  const id = text(model.id, `${label}.id`, 256)
170
+ const type = model.type === undefined ? 'unknown' : model.type
171
+ if (!MODEL_TYPES.includes(type)) throw new Error(`${label}.type must be one of ${MODEL_TYPES.join(', ')}`)
165
172
  if (seen.has(id)) throw new Error(`${providerID} repeats model ${id}`)
166
173
  seen.add(id)
167
174
  const input = model.input ?? ['text']
@@ -184,6 +191,7 @@ export function normalizeModels(models, providerID) {
184
191
  }
185
192
  return Object.freeze({
186
193
  id,
194
+ type,
187
195
  name: model.name === undefined ? id : text(model.name, `${providerID}.${id}.name`, 256),
188
196
  input: Object.freeze([...new Set(input)]),
189
197
  ...(model.contextWindow === undefined ? {} : { contextWindow: positiveInteger(model.contextWindow, `${providerID}.${id}.contextWindow`) }),
@@ -258,15 +266,22 @@ export function normalizeEnterpriseProfile(raw) {
258
266
  let auth
259
267
  if (gateway) {
260
268
  const rawAuth = object(source.auth, 'profile.auth')
261
- exactKeys(rawAuth, new Set(['discoveryUrl', 'expectedIssuer', 'experimentalOidcLlm', 'clientId', 'identityMode']), 'profile.auth')
269
+ exactKeys(rawAuth, new Set(['discoveryUrl', 'expectedIssuer', 'experimentalOidcLlm', 'clientId', 'identityMode', 'additionalScopes']), 'profile.auth')
262
270
  const experimental = rawAuth.experimentalOidcLlm === true
263
271
  if (rawAuth.experimentalOidcLlm !== undefined && typeof rawAuth.experimentalOidcLlm !== 'boolean') throw new Error('profile.auth.experimentalOidcLlm must be boolean')
264
- if (!experimental && (rawAuth.clientId !== undefined || rawAuth.identityMode !== undefined)) throw new Error('profile.auth clientId and identityMode require experimentalOidcLlm=true')
272
+ if (!experimental && (rawAuth.clientId !== undefined || rawAuth.identityMode !== undefined || rawAuth.additionalScopes !== undefined)) throw new Error('profile.auth clientId, identityMode and additionalScopes require experimentalOidcLlm=true')
265
273
  if (experimental && !['oauth', 'oidc'].includes(rawAuth.identityMode)) throw new Error('profile.auth.identityMode must explicitly select oauth or oidc')
274
+ if (rawAuth.additionalScopes !== undefined && (!Array.isArray(rawAuth.additionalScopes)
275
+ || rawAuth.additionalScopes.length > 64
276
+ || rawAuth.additionalScopes.some(scope => typeof scope !== 'string' || scope.length > 128 || !/^[\x21\x23-\x5b\x5d-\x7e]+$/.test(scope))
277
+ || new Set(rawAuth.additionalScopes).size !== rawAuth.additionalScopes.length)) {
278
+ throw new Error('profile.auth.additionalScopes must contain at most 64 unique OAuth scope tokens, each at most 128 characters')
279
+ }
266
280
  auth = Object.freeze({
267
281
  discoveryUrl: issuerURL(rawAuth.discoveryUrl, 'profile.auth.discoveryUrl', allowInsecureDevelopment),
268
282
  ...(rawAuth.expectedIssuer === undefined ? {} : { expectedIssuer: issuerURL(rawAuth.expectedIssuer, 'profile.auth.expectedIssuer', allowInsecureDevelopment) }),
269
283
  ...(experimental ? { experimentalOidcLlm: true, clientId: text(rawAuth.clientId, 'profile.auth.clientId', 256), identityMode: rawAuth.identityMode } : {}),
284
+ ...(rawAuth.additionalScopes === undefined ? {} : { additionalScopes: Object.freeze([...rawAuth.additionalScopes]) }),
270
285
  })
271
286
  if (source.provider?.baseURL !== undefined) throw new Error('profile.auth derives provider.baseURL from validated discovery')
272
287
  if (source.provider?.modelSource !== undefined && source.provider.modelSource !== 'discovery') throw new Error('profile.auth requires discovery modelSource')
@@ -354,7 +369,7 @@ export function loadEnterpriseProfiles(raw = {}, environment = process.env) {
354
369
 
355
370
  export function enterpriseProviderConfig(profiles) {
356
371
  return {
357
- providers: Object.fromEntries([...profiles.values()].filter(profile => profile.provider?.baseURL && profile.provider.models.length).map(profile => [profile.provider.id, {
372
+ providers: Object.fromEntries([...profiles.values()].filter(profile => profile.provider?.baseURL && profile.provider.models.some(isLLM)).map(profile => [profile.provider.id, {
358
373
  displayName: profile.provider.displayName,
359
374
  apiKeyEnv: `DSH_GATEWAY_${profile.id.toUpperCase().replace(/-/g, '_')}_ACCESS`,
360
375
  baseURL: profile.provider.baseURL,
@@ -368,7 +383,7 @@ export function enterpriseProviderConfig(profiles) {
368
383
  ...(profile.provider.streamIdleTimeoutMs === undefined ? {} : { streamIdleTimeoutMs: profile.provider.streamIdleTimeoutMs }),
369
384
  ...(profile.provider.retryPolicy === undefined ? {} : { retryPolicy: structuredClone(profile.provider.retryPolicy) }),
370
385
  ...(profile.provider.compat === undefined ? {} : { compat: { ...profile.provider.compat } }),
371
- models: profile.provider.models.map(model => ({
386
+ models: profile.provider.models.filter(isLLM).map(({ type, ...model }) => ({
372
387
  ...model,
373
388
  input: [...model.input],
374
389
  ...(model.reasoningEfforts && typeof model.reasoningEfforts === 'object'
@@ -390,7 +405,7 @@ export function publicProfile(profile) {
390
405
  ...(profile.provider ? { provider: {
391
406
  id: profile.provider.id,
392
407
  displayName: profile.provider.displayName,
393
- models: profile.provider.models.map(model => ({ id: model.id, name: model.name, input: model.input })),
408
+ models: profile.provider.models.filter(isLLM).map(model => ({ id: model.id, name: model.name, input: model.input })),
394
409
  } } : {}),
395
410
  }
396
411
  }
@@ -9,6 +9,10 @@ export class AuthorizationScopedAdapter {
9
9
  providerInfo(provider) { return this.inner.providerInfo(provider) }
10
10
  providerRetryPolicy(provider) { return this.inner.providerRetryPolicy(provider) }
11
11
  imageRequestPricing(provider, model) { return this.inner.imageRequestPricing(provider, model) }
12
+ imageRequestTarget(provider, model, image) {
13
+ return this.inner.imageRequestTarget?.(provider, model, image)
14
+ ?? { width: image.width, height: image.height, maxBytes: image.bytes }
15
+ }
12
16
  listModels(provider) { return this.inner.listModels(provider) }
13
17
  resolveModel(provider, model, signal) { return this.inner.resolveModel(provider, model, signal) }
14
18
 
@@ -174,16 +174,16 @@ export function resolveEnterpriseProfiles(raw = {}) {
174
174
  })
175
175
  }
176
176
 
177
- export function configurableEntries(profiles) {
177
+ export function configurableEntries(profiles, settingsNs = ENTERPRISE_SETTINGS_NAMESPACE) {
178
178
  return [...profiles.values()].map(profile => ({
179
179
  provider: profile.provider,
180
180
  displayName: profile.displayName,
181
- settingsNs: ENTERPRISE_SETTINGS_NAMESPACE,
181
+ settingsNs,
182
182
  settingsPath: ['providers', profile.provider],
183
183
  declared: true,
184
184
  }))
185
185
  }
186
186
 
187
187
  export function settingsBase(rawConfig) {
188
- return { providers: rawConfig.providers ?? {} }
188
+ return { providers: typeof rawConfig.providers?.get === 'function' ? rawConfig.providers.get() : rawConfig.providers ?? {} }
189
189
  }
@@ -16,9 +16,22 @@ import { AuthorizationScopedAdapter } from './authorization-scope.js'
16
16
  export const name = 'dsh-oidc-provider'
17
17
  export const inject = ['llm']
18
18
  export const SETTINGS_NAMESPACE = ENTERPRISE_SETTINGS_NAMESPACE
19
+ export const Config = PiAiConfig
19
20
 
20
21
  function installEnterpriseSettings(ctx, rawConfig, hooks) {
21
22
  ctx.inject(['settings'], (settingsCtx) => {
23
+ if (typeof settingsCtx.settings.installSection !== 'function') {
24
+ // Account-managed routes come from the organization profile and its
25
+ // authorization lease. Never project them into editable API-key forms.
26
+ if (rawConfig.settingsEnabled !== true) return
27
+ if (typeof rawConfig.providers?.get !== 'function' || !ctx.fiber.entry) {
28
+ throw new Error('Editable enterprise providers require a DSH profile entry with live Config')
29
+ }
30
+ settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber))
31
+ hooks.setSource(() => settingsBase(rawConfig))
32
+ ctx.on('loader/volatile-update', () => hooks.onChange())
33
+ return
34
+ }
22
35
  settingsCtx.settings.installSection(
23
36
  ctx,
24
37
  SETTINGS_NAMESPACE,
@@ -147,14 +160,17 @@ export function apply(ctx, rawConfig = {}) {
147
160
  : transforming
148
161
  let registration
149
162
  let directory
163
+ const nativeSettings = typeof ctx.get('settings')?.installSection !== 'function'
164
+ const editableDirectory = !nativeSettings || rawConfig.settingsEnabled === true
165
+ const settingsNs = nativeSettings ? ctx.fiber.entry?.options.id ?? SETTINGS_NAMESPACE : SETTINGS_NAMESPACE
150
166
  const replaceProfiles = next => {
151
167
  const previous = profiles
152
168
  profiles = next
153
169
  try {
154
170
  if (registration) registration.replace([...next.keys()])
155
171
  else if (next.size) registration = ctx.llm.registerAdapter([...next.keys()], adapter)
156
- if (directory) directory.replace(configurableEntries(next))
157
- else if (next.size) directory = ctx.llm.registerConfigurableProviders(configurableEntries(next))
172
+ if (directory) directory.replace(configurableEntries(next, settingsNs))
173
+ else if (next.size && editableDirectory) directory = ctx.llm.registerConfigurableProviders(configurableEntries(next, settingsNs))
158
174
  } catch (error) {
159
175
  profiles = previous
160
176
  registration?.replace([...previous.keys()])
@@ -37,6 +37,10 @@ export class TransformingEnterpriseAdapter {
37
37
  providerInfo(provider) { return this.inner.providerInfo(provider) }
38
38
  providerRetryPolicy(provider) { return this.inner.providerRetryPolicy(provider) }
39
39
  imageRequestPricing(provider, model) { return this.inner.imageRequestPricing(provider, model) }
40
+ imageRequestTarget(provider, model, image) {
41
+ return this.inner.imageRequestTarget?.(provider, model, image)
42
+ ?? { width: image.width, height: image.height, maxBytes: image.bytes }
43
+ }
40
44
 
41
45
  async listModels(provider) {
42
46
  const models = await this.inner.listModels(provider)
package/lib/resources.js CHANGED
@@ -1,16 +1,24 @@
1
1
  import { normalizeModels } from './profile.js'
2
+ import { MODEL_TYPES } from './model-types.js'
2
3
 
3
- // The public resource contract extends the deployed worker wire protocol. OIDC
4
- // access tokens remain on the management plane; these calls use the model key.
4
+ // A gateway catalog may also contain embedding, ranking and media models.
5
+ // Type metadata classifies authorized rows; it never grants model access.
5
6
  export function normalizeResourceModels(raw, profile) {
6
7
  if (!Array.isArray(raw?.data) || raw.data.length > 128) throw new Error('invalid model catalog')
7
8
  const reviewed = new Map(profile.provider.models.map(model => [model.id, model]))
8
9
  const models = raw.data.map(row => {
9
10
  const id = typeof row?.id === 'string' ? row.id.trim() : ''
11
+ const local = reviewed.get(id)
12
+ // The current server declaration wins. Local metadata fills an absent type,
13
+ // but cannot turn an explicit unknown/future server type into a chat model.
14
+ const type = row.type === undefined
15
+ ? local?.type ?? 'unknown'
16
+ : MODEL_TYPES.includes(row.type) ? row.type : 'unknown'
10
17
  // A plain /models response does not prove reasoning or vision support.
11
- return reviewed.get(id) ?? { id, name: id, input: ['text'], reasoning: false }
18
+ return { ...(local ?? { id, name: id, input: ['text'], reasoning: false }), type }
12
19
  })
13
- return normalizeModels(models, profile.provider.id)
20
+ // Keep the full typed catalog; only the DSH conversation projection uses LLMs.
21
+ return models.length === 0 ? Object.freeze([]) : normalizeModels(models, profile.provider.id)
14
22
  }
15
23
 
16
24
  export function emptyResources(profile) {
@@ -0,0 +1,15 @@
1
+ const ACCESS_REFRESH_SECONDS = 30 * 60
2
+
3
+ // Refresh early without repeatedly renewing tokens whose entire lifetime is
4
+ // shorter than the normal margin. These timestamps stay in the Host vault.
5
+ export function accessTokenTiming(expiresIn, now) {
6
+ const expiresAt = Math.floor(now() / 1000) + expiresIn
7
+ return { expiresAt, refreshAt: expiresAt - Math.min(ACCESS_REFRESH_SECONDS, expiresIn / 2) }
8
+ }
9
+
10
+ export function accessTokenNeedsRefresh(session, now) {
11
+ // Existing installations have only expiresAt. Their first refresh adds the
12
+ // lifetime-aware timestamp; no migration or new sign-in is required.
13
+ const refreshAt = Number.isFinite(session.refreshAt) ? session.refreshAt : session.expiresAt - ACCESS_REFRESH_SECONDS
14
+ return now() / 1000 >= Math.min(session.expiresAt, refreshAt)
15
+ }
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod'
2
+ import { MODEL_TYPES } from './model-types.js'
2
3
 
3
4
  const pkg = '@eduwork/dsh-oidc'
4
5
  export const profileIdSchema = z.string().min(1).max(64)
@@ -8,11 +9,11 @@ export const baseURLSchema = z.string().min(1).max(2048)
8
9
  export const modelCatalogModeSchema = z.enum(['discovery', 'manual'])
9
10
  export const configurationTargetSchema = z.enum(['config', 'examples'])
10
11
  export const openConfigurationResult = Object.freeze({
11
- mode: 'strict', typeSymbol: `${pkg}#OpenConfigurationResult`,
12
+ mode: 'strict', create() { return this.schema }, typeSymbol: `${pkg}#OpenConfigurationResult`,
12
13
  schema: z.object({ opened: z.literal(true) }).strict(),
13
14
  })
14
15
  export const enterpriseSelectionResult = Object.freeze({
15
- mode: 'strict', typeSymbol: `${pkg}#EnterpriseSelectionResult`,
16
+ mode: 'strict', create() { return this.schema }, typeSymbol: `${pkg}#EnterpriseSelectionResult`,
16
17
  schema: z.object({ changed: z.boolean(), selection: z.object({ provider: z.string(), model: z.string(), reasoningEffort: z.string().optional() }).strict().optional() }).strict(),
17
18
  })
18
19
  export const enterpriseSelectionOptionsSchema = z.object({ onlyIfMissing: z.boolean().optional() }).strict()
@@ -20,7 +21,7 @@ export const enterpriseSelectionOptionsSchema = z.object({ onlyIfMissing: z.bool
20
21
  const brandSchema = z.object({
21
22
  productName: z.string().optional(), organizationName: z.string().optional(), mark: z.string().optional(),
22
23
  logoURL: z.string().optional(), primaryColor: z.string().optional(), loginTitle: z.string().optional(),
23
- loginDescription: z.string().optional(), supportURL: z.string().optional(),
24
+ loginDescription: z.string().optional(), loginButtonLabel: z.string().min(1).max(40).optional(), supportURL: z.string().optional(), language: z.enum(['auto', 'zh-CN', 'en']).optional(),
24
25
  }).strict()
25
26
 
26
27
  const publicProfileSchema = z.object({
@@ -50,11 +51,12 @@ export const runtimeModelSchema = z.object({
50
51
  }).strict()
51
52
 
52
53
  export const runtimeModelsSchema = z.array(runtimeModelSchema).max(128)
54
+ export const resourceModelSchema = runtimeModelSchema.extend({ type: z.enum(MODEL_TYPES) })
53
55
 
54
56
  export const resourcesResult = Object.freeze({
55
- mode: 'strict', typeSymbol: `${pkg}#Resources`,
57
+ mode: 'strict', create() { return this.schema }, typeSymbol: `${pkg}#Resources`,
56
58
  schema: z.object({
57
- profileID: z.string(), modelSource: z.string(), models: runtimeModelsSchema, issues: z.array(z.string()),
59
+ profileID: z.string(), modelSource: z.string(), models: z.array(resourceModelSchema).max(128), issues: z.array(z.string()),
58
60
  }).strict(),
59
61
  })
60
62
 
@@ -69,14 +71,14 @@ const managementProfileSchema = z.object({
69
71
  }).strict()
70
72
 
71
73
  export const configurationResult = Object.freeze({
72
- mode: 'strict', typeSymbol: `${pkg}#Configuration`,
74
+ mode: 'strict', create() { return this.schema }, typeSymbol: `${pkg}#Configuration`,
73
75
  schema: z.object({ schemaVersion: z.literal('dsh-oidc/v1alpha1'), uiMode: z.enum(['standard', 'models-only', 'external']), profiles: z.array(publicProfileSchema),
74
76
  manageProductBrand: z.boolean().optional(), configFile: z.object({ path: z.string(), examplesPath: z.string(), canOpen: z.boolean() }).strict().optional(),
75
77
  }).strict(),
76
78
  })
77
79
 
78
80
  export const managementResult = Object.freeze({
79
- mode: 'strict', typeSymbol: `${pkg}#ManagementConfiguration`,
81
+ mode: 'strict', create() { return this.schema }, typeSymbol: `${pkg}#ManagementConfiguration`,
80
82
  schema: z.object({
81
83
  schemaVersion: z.literal('dsh-oidc/management/v1alpha1'), mode: z.enum(['profile', 'native']),
82
84
  activeProfileID: z.string(), restartRequired: z.boolean(),
@@ -86,14 +88,14 @@ export const managementResult = Object.freeze({
86
88
  })
87
89
 
88
90
  export const restartResult = Object.freeze({
89
- mode: 'strict', typeSymbol: `${pkg}#RestartResult`,
91
+ mode: 'strict', create() { return this.schema }, typeSymbol: `${pkg}#RestartResult`,
90
92
  schema: z.object({ restarting: z.literal(true) }).strict(),
91
93
  })
92
94
 
93
- export const accountResult = Object.freeze({ mode: 'strict', typeSymbol: `${pkg}#AccountStatus`, schema: accountSchema })
95
+ export const accountResult = Object.freeze({ mode: 'strict', create() { return this.schema }, typeSymbol: `${pkg}#AccountStatus`, schema: accountSchema })
94
96
 
95
97
  export const beginResult = Object.freeze({
96
- mode: 'strict', typeSymbol: `${pkg}#BeginResult`,
98
+ mode: 'strict', create() { return this.schema }, typeSymbol: `${pkg}#BeginResult`,
97
99
  schema: z.union([
98
100
  z.object({ mode: z.literal('redirect'), authorizationURL: z.string().url() }).strict(),
99
101
  z.object({ mode: z.literal('completed'), status: accountSchema }).strict(),
@@ -102,7 +104,7 @@ export const beginResult = Object.freeze({
102
104
  })
103
105
 
104
106
  export const loginResult = Object.freeze({
105
- mode: 'strict', typeSymbol: `${pkg}#LoginResult`,
107
+ mode: 'strict', create() { return this.schema }, typeSymbol: `${pkg}#LoginResult`,
106
108
  schema: z.object({
107
109
  loginID: loginIdSchema, profileID: profileIdSchema, expiresAt: z.string().datetime(),
108
110
  state: z.enum(['pending', 'completed', 'cancelled', 'expired', 'failed']),
@@ -111,5 +113,5 @@ export const loginResult = Object.freeze({
111
113
  })
112
114
 
113
115
  export function jsonParameter(name, schema, typeSymbol) {
114
- return Object.freeze({ name, wire: name, source: 'json', codec: Object.freeze({ mode: 'strict', typeSymbol, schema }) })
116
+ return Object.freeze({ name, wire: name, source: 'json', codec: Object.freeze({ mode: 'strict', create() { return this.schema }, typeSymbol, schema }) })
115
117
  }