@eduwork/dsh-oidc 0.3.0-dev.1 → 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 (59) hide show
  1. package/CHANGELOG.en.md +12 -0
  2. package/CHANGELOG.md +12 -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 +49 -2
  16. package/docs/enterprise-profile.md +49 -2
  17. package/docs/gateway-auth/README.md +7 -6
  18. package/docs/gateway-auth/README_EN.md +7 -6
  19. package/docs/gateway-auth/experimental-oidc-llm.en.md +10 -8
  20. package/docs/gateway-auth/experimental-oidc-llm.md +9 -7
  21. package/docs/gateway-auth/litellm-setup.en.md +10 -4
  22. package/docs/gateway-auth/litellm-setup.md +10 -4
  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 +4 -1
  34. package/examples/enterprise-profile.example.json +4 -1
  35. package/examples/identity-only.example.json +7 -2
  36. package/examples/litellm.enterprise-profile.example.json +9 -0
  37. package/examples/oidc-llm.enterprise-profile.example.json +33 -0
  38. package/examples/resources-discovery.example.json +9 -2
  39. package/lib/callback-page.js +9 -4
  40. package/lib/client.js +460 -360
  41. package/lib/desktop-oidc.js +2 -2
  42. package/lib/gateway-backend.js +20 -5
  43. package/lib/index.js +8 -6
  44. package/lib/litellm-protocol.js +4 -3
  45. package/lib/model-types.js +7 -0
  46. package/lib/oidc-llm-protocol.js +8 -5
  47. package/lib/oidc.js +22 -28
  48. package/lib/profile.js +36 -47
  49. package/lib/provider/authorization-scope.js +4 -0
  50. package/lib/provider/core.js +8 -19
  51. package/lib/provider/index.js +18 -2
  52. package/lib/provider/transform-adapter.js +4 -0
  53. package/lib/resources.js +12 -4
  54. package/lib/token-lifetime.js +15 -0
  55. package/lib/transport.js +5 -0
  56. package/lib/typert-schemas.js +14 -12
  57. package/package.json +104 -97
  58. package/protocol/resources.openapi.yaml +11 -3
  59. package/schema/enterprise-profile.v1alpha1.schema.json +40 -5
@@ -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,3 +1,5 @@
1
+ import { serviceProtocolAllowed } from './transport.js'
2
+ import { accessTokenTiming } from './token-lifetime.js'
1
3
  // LiteLLM v1.101.0, native CLI auth contract 1. This is OAuth, not OIDC.
2
4
  // Protocol-specific wire fields stay here; tokens never cross the renderer RPC.
3
5
  const MAX_BYTES = 1024 * 1024
@@ -51,8 +53,7 @@ export async function gatewayJSON(fetcher, url, init = {}) {
51
53
  function trustedURL(value, profile, origin) {
52
54
  let url
53
55
  try { url = new URL(value) } catch { throw protocolError('gateway_discovery_invalid', 'Gateway metadata contains an invalid URL') }
54
- const local = profile.allowInsecureDevelopment && url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)
55
- if (!nonempty(value, 2048) || (url.protocol !== 'https:' && !local) || url.username || url.password || url.search || url.hash || (origin && url.origin !== origin)) {
56
+ if (!nonempty(value, 2048) || !serviceProtocolAllowed(url, profile.allowInsecureDevelopment) || url.username || url.password || url.search || url.hash || (origin && url.origin !== origin)) {
56
57
  throw protocolError('gateway_discovery_invalid', 'Gateway metadata URL is outside the configured trust boundary')
57
58
  }
58
59
  return url
@@ -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,3 +1,5 @@
1
+ import { serviceProtocolAllowed } from './transport.js'
2
+ import { accessTokenTiming } from './token-lifetime.js'
1
3
  // Experimental draft 0.1. These wire rules are not a published standard.
2
4
  import { protocolError } from './litellm-protocol.js'
3
5
 
@@ -10,9 +12,7 @@ const includes = (raw, key, values) => {
10
12
  function endpoint(value, profile, origin) {
11
13
  let url
12
14
  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)
15
+ if (typeof value !== 'string' || value.length > 2048 || !serviceProtocolAllowed(url, profile.allowInsecureDevelopment)
16
16
  || url.username || url.password || url.search || url.hash || origin && url.origin !== origin) fail('oidc-llm URL is outside the configured trust boundary')
17
17
  return url
18
18
  }
@@ -31,9 +31,12 @@ export function detectOidcLlm(raw, profile) {
31
31
  const identityMode = profile.auth.identityMode
32
32
  includes(extension, 'identity_modes_supported', ['oauth', identityMode])
33
33
  includes(extension, 'client_registration_methods_supported', ['static'])
34
- const scopes = identityMode === 'oidc'
34
+ const identityScopes = identityMode === 'oidc'
35
35
  ? ['openid', 'profile', 'offline_access', 'llm:models:read', 'llm:invoke']
36
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 ?? [])])]
37
40
  for (const [key, values] of Object.entries({
38
41
  response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'],
39
42
  code_challenge_methods_supported: ['S256'], token_endpoint_auth_methods_supported: ['none'],
@@ -74,7 +77,7 @@ export function oidcLlmToken(raw, descriptor, now, previous) {
74
77
  throw protocolError('gateway_scope_changed', 'oidc-llm granted scopes do not match the requested authorization')
75
78
  }
76
79
  // 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 }
80
+ return { accessToken: raw.access_token, refreshToken: raw.refresh_token, ...accessTokenTiming(raw.expires_in, now), scopes, teamID: null }
78
81
  }
79
82
 
80
83
  export function oidcLlmIdentity(raw) {
package/lib/oidc.js CHANGED
@@ -1,10 +1,11 @@
1
+ import { serviceProtocolAllowed } from './transport.js'
1
2
  import { createHash, createPublicKey, randomBytes, timingSafeEqual, verify as verifySignature } from 'node:crypto'
2
3
  import { emptyResources } from './resources.js'
4
+ import { accessTokenNeedsRefresh, accessTokenTiming } from './token-lifetime.js'
3
5
 
4
6
  const FLOW_TTL_MS = 10 * 60 * 1000
5
7
  const MAX_PENDING_FLOWS = 32
6
8
  const CLOCK_SKEW_SECONDS = 60
7
- const ACCESS_REFRESH_SECONDS = 90
8
9
  const MAX_RESPONSE_BYTES = 1024 * 1024
9
10
  export const OIDC_CALLBACK_PATH = '/oauth/callback'
10
11
 
@@ -43,10 +44,6 @@ async function responseJSON(response, stage) {
43
44
  return value
44
45
  }
45
46
 
46
- function isLoopback(hostname) {
47
- return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'
48
- }
49
-
50
47
  /** OIDC metadata endpoints may use origins distinct from the issuer over HTTPS. */
51
48
  function discoveredEndpoint(value, profile, label, optional = false) {
52
49
  if (optional && (value === undefined || value === '')) return ''
@@ -54,17 +51,8 @@ function discoveredEndpoint(value, profile, label, optional = false) {
54
51
  let endpoint
55
52
  try { endpoint = new URL(value) }
56
53
  catch (cause) { throw publicError('oidc_discovery_invalid', `OIDC ${label} is not an absolute URL`, cause) }
57
- const issuerURL = new URL(profile.oidc.issuer)
58
- const secure = endpoint.protocol === 'https:'
59
- const localDevelopment = issuerURL.protocol === 'http:' && isLoopback(issuerURL.hostname)
60
- && endpoint.protocol === 'http:' && isLoopback(endpoint.hostname)
61
- const explicitlyAllowedDevelopment = profile.allowInsecureDevelopment === true
62
- && typeof profile.insecureDevelopmentOrigin === 'string'
63
- && issuerURL.origin === profile.insecureDevelopmentOrigin
64
- && endpoint.protocol === 'http:'
65
- && endpoint.origin === profile.insecureDevelopmentOrigin
66
- if ((!secure && !localDevelopment && !explicitlyAllowedDevelopment) || endpoint.username || endpoint.password || endpoint.hash) {
67
- 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`)
68
56
  }
69
57
  return endpoint.toString()
70
58
  }
@@ -237,7 +225,7 @@ export class WebOidcBackend {
237
225
  let response
238
226
  try {
239
227
  response = await this.fetch(discoveryURL(profile.oidc.issuer), {
240
- headers: { accept: 'application/json' }, signal: AbortSignal.timeout(20_000),
228
+ headers: { accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(20_000),
241
229
  })
242
230
  } catch (cause) {
243
231
  throw publicError('oidc_discovery_failed', 'unable to read OIDC discovery metadata', cause)
@@ -340,7 +328,7 @@ export class WebOidcBackend {
340
328
  })
341
329
  const response = await this.fetch(discovery.tokenEndpoint, {
342
330
  method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
343
- body, signal: AbortSignal.timeout(20_000),
331
+ body, redirect: 'error', signal: AbortSignal.timeout(20_000),
344
332
  })
345
333
  const token = await responseJSON(response, 'oidc_token_request_failed')
346
334
  if (typeof token.access_token !== 'string' || typeof token.id_token !== 'string' || token.token_type?.toLowerCase() !== 'bearer') {
@@ -348,6 +336,7 @@ export class WebOidcBackend {
348
336
  }
349
337
  const expiresIn = token.expires_in === undefined ? 3600 : Number(token.expires_in)
350
338
  if (!Number.isFinite(expiresIn) || expiresIn <= 0) throw publicError('oidc_token_invalid', 'OIDC token expiry is invalid')
339
+ const timing = accessTokenTiming(expiresIn, this.now)
351
340
  const claims = await this.verifyIDToken(profile, discovery, token.id_token, nonce, token.access_token)
352
341
  const userInfo = await this.userInfo(discovery.userInfoEndpoint, token.access_token)
353
342
  if (!equalText(userInfo.sub, claims.sub)) throw publicError('oidc_userinfo_invalid', 'OIDC UserInfo subject does not match ID Token')
@@ -358,7 +347,7 @@ export class WebOidcBackend {
358
347
  clientId: profile.oidc.clientId,
359
348
  accessToken: token.access_token,
360
349
  refreshToken: typeof token.refresh_token === 'string' ? token.refresh_token : '',
361
- expiresAt: nowSeconds(this.now) + Math.max(60, expiresIn),
350
+ ...timing,
362
351
  identity: {
363
352
  sub: claims.sub,
364
353
  name: userName,
@@ -399,9 +388,12 @@ export class WebOidcBackend {
399
388
  throw publicError('oidc_id_token_invalid', 'OIDC ID Token identity claims are invalid')
400
389
  }
401
390
  if (!Number.isFinite(claims.exp) || !Number.isFinite(claims.iat)
402
- || claims.exp < now - CLOCK_SKEW_SECONDS || claims.iat > now + CLOCK_SKEW_SECONDS
403
- || (claims.nbf !== undefined && (!Number.isFinite(claims.nbf) || claims.nbf > now + CLOCK_SKEW_SECONDS))) {
404
- 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')
405
397
  }
406
398
  if (claims.at_hash !== undefined && (typeof claims.at_hash !== 'string' || !verifyAccessTokenHash(accessToken, claims.at_hash))) {
407
399
  throw publicError('oidc_id_token_invalid', 'OIDC ID Token access-token hash is invalid')
@@ -411,7 +403,7 @@ export class WebOidcBackend {
411
403
 
412
404
  async userInfo(endpoint, accessToken) {
413
405
  const response = await this.fetch(endpoint, {
414
- 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),
415
407
  })
416
408
  const result = await responseJSON(response, 'oidc_userinfo_failed')
417
409
  if (typeof result.sub !== 'string' || result.sub === '') throw publicError('oidc_userinfo_invalid', 'OIDC UserInfo has no subject')
@@ -471,13 +463,14 @@ export class WebOidcBackend {
471
463
  const epoch = this.accountEpoch(profile)
472
464
  const saved = await this.loadSession(profile)
473
465
  if (!saved) throw publicError('oidc_login_required', 'OIDC session is no longer available')
474
- 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
475
468
  if (!session.refreshToken) throw publicError('oidc_login_required', 'OIDC session cannot be refreshed')
476
469
  const discovery = await this.discover(profile)
477
470
  const response = await this.fetch(discovery.tokenEndpoint, {
478
471
  method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
479
472
  body: new URLSearchParams({ grant_type: 'refresh_token', client_id: profile.oidc.clientId, refresh_token: session.refreshToken }),
480
- signal: AbortSignal.timeout(20_000),
473
+ redirect: 'error', signal: AbortSignal.timeout(20_000),
481
474
  })
482
475
  let token
483
476
  try { token = await responseJSON(response, 'oidc_refresh_failed') }
@@ -498,7 +491,7 @@ export class WebOidcBackend {
498
491
  ...session,
499
492
  accessToken: token.access_token,
500
493
  refreshToken: typeof token.refresh_token === 'string' ? token.refresh_token : session.refreshToken,
501
- expiresAt: nowSeconds(this.now) + Math.max(60, expiresIn),
494
+ ...accessTokenTiming(expiresIn, this.now),
502
495
  }
503
496
  await this.saveSession(profile, next, epoch)
504
497
  return next
@@ -508,8 +501,9 @@ export class WebOidcBackend {
508
501
  const epoch = this.accountEpoch(profile)
509
502
  let session = await this.loadSession(profile)
510
503
  if (!session) return undefined
511
- if (session.expiresAt <= nowSeconds(this.now) + ACCESS_REFRESH_SECONDS) {
504
+ if (accessTokenNeedsRefresh(session, this.now)) {
512
505
  if (!session.refreshToken) {
506
+ if (session.expiresAt > nowSeconds(this.now)) return session
513
507
  await this.clearCredentials(profile, epoch)
514
508
  this.accountChanged(account(profile, undefined, false))
515
509
  return undefined
@@ -607,7 +601,7 @@ export class WebOidcBackend {
607
601
  const token = session.refreshToken || session.accessToken
608
602
  await this.fetch(discovery.revocationEndpoint, {
609
603
  method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
610
- 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),
611
605
  })
612
606
  }
613
607
  } catch (cause) { this.ctx.logger.warn(cause instanceof Error ? cause : new Error(String(cause))) }
package/lib/profile.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { serviceProtocolAllowed } from './transport.js'
2
+ import { MODEL_TYPES, isLLM } from './model-types.js'
1
3
  import { readFileSync } from 'node:fs'
2
4
 
3
5
  export const PROFILE_SCHEMA_VERSION = 'dsh-oidc/v1alpha1'
@@ -18,7 +20,7 @@ const allowedRootKeys = new Set([
18
20
  const allowedOidcKeys = new Set(['issuer', 'clientId', 'scopes'])
19
21
  const allowedBrandKeys = new Set([
20
22
  'productName', 'organizationName', 'mark', 'logoURL', 'primaryColor',
21
- 'loginTitle', 'loginDescription', 'supportURL',
23
+ 'loginTitle', 'loginDescription', 'loginButtonLabel', 'supportURL', 'language',
22
24
  ])
23
25
  const allowedProviderKeys = new Set([
24
26
  'id', 'displayName', 'adapter', 'baseURL', 'reasoning', 'defaultContextWindow',
@@ -26,7 +28,7 @@ const allowedProviderKeys = new Set([
26
28
  'requestImageMaxBytes', 'streamIdleTimeoutMs', 'retryPolicy', 'compat', 'models', 'modelSource',
27
29
  ])
28
30
  const allowedModelKeys = new Set([
29
- 'id', 'name', 'input', 'contextWindow', 'maxTokens', 'reasoning',
31
+ 'id', 'type', 'name', 'input', 'contextWindow', 'maxTokens', 'reasoning',
30
32
  'reasoningEfforts', 'defaultReasoningEffort', 'compat',
31
33
  ])
32
34
  const allowedCompatKeys = new Set([
@@ -52,41 +54,22 @@ function text(value, label, max = 2048) {
52
54
  return value
53
55
  }
54
56
 
55
- function isLoopback(hostname) {
56
- return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'
57
- }
58
-
59
- function insecureDevelopmentOrigin(value, label) {
57
+ function issuerURL(value, label, allowInsecure = false) {
60
58
  const raw = text(value, label)
61
59
  const parsed = new URL(raw)
62
- if (parsed.protocol !== 'http:' || parsed.username || parsed.password || parsed.search || parsed.hash || (parsed.pathname !== '/' && parsed.pathname !== '')) {
63
- throw new Error(`${label} must be an exact HTTP origin without credentials, path, query, or fragment`)
64
- }
65
- return raw.replace(/\/+$/, '')
66
- }
67
-
68
- function endpointProtocolAllowed(parsed, allowInsecure, allowedInsecureOrigin) {
69
- return parsed.protocol === 'https:' || (allowInsecure && parsed.protocol === 'http:' && (
70
- isLoopback(parsed.hostname) || parsed.origin === allowedInsecureOrigin
71
- ))
72
- }
73
-
74
- function issuerURL(value, label, allowInsecure = false, allowedInsecureOrigin) {
75
- const raw = text(value, label)
76
- const parsed = new URL(raw)
77
- const permitted = endpointProtocolAllowed(parsed, allowInsecure, allowedInsecureOrigin)
60
+ const permitted = serviceProtocolAllowed(parsed, allowInsecure)
78
61
  if (!permitted || parsed.username || parsed.password || parsed.search || parsed.hash) {
79
- throw new Error(`${label} must be an HTTPS issuer URL without credentials, query, or fragment${allowInsecure ? ' (loopback or the exact allowlisted HTTP origin is allowed in development)' : ''}`)
62
+ throw new Error(`${label} must be an HTTPS issuer URL without credentials, query, or fragment${allowInsecure ? ' (HTTP is allowed with allowInsecureDevelopment=true)' : '; set allowInsecureDevelopment=true to allow HTTP'}`)
80
63
  }
81
64
  return raw
82
65
  }
83
66
 
84
- function exactURL(value, label, allowInsecure = false, allowedInsecureOrigin) {
67
+ function exactURL(value, label, allowInsecure = false) {
85
68
  const raw = text(value, label)
86
69
  const parsed = new URL(raw)
87
- const permitted = endpointProtocolAllowed(parsed, allowInsecure, allowedInsecureOrigin)
70
+ const permitted = serviceProtocolAllowed(parsed, allowInsecure)
88
71
  if (!permitted || parsed.username || parsed.password || parsed.hash || parsed.search) {
89
- throw new Error(`${label} must be an absolute HTTPS URL without credentials, query, or fragment${allowInsecure ? ' (loopback or the exact allowlisted HTTP origin is allowed in development)' : ''}`)
72
+ throw new Error(`${label} must be an absolute HTTPS URL without credentials, query, or fragment${allowInsecure ? ' (HTTP is allowed with allowInsecureDevelopment=true)' : '; set allowInsecureDevelopment=true to allow HTTP'}`)
90
73
  }
91
74
  return raw.replace(/\/+$/, '')
92
75
  }
@@ -132,10 +115,14 @@ function normalizeBrand(value = {}) {
132
115
  }
133
116
  result.logoURL = logo.startsWith('https://') ? safeHTTPSResource(logo, 'brand.logoURL', 128 * 1024) : logo
134
117
  }
135
- for (const key of ['loginTitle', 'loginDescription']) {
136
- 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)
137
120
  }
138
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
+ }
139
126
  return Object.freeze(result)
140
127
  }
141
128
 
@@ -180,6 +167,8 @@ export function normalizeModels(models, providerID) {
180
167
  const model = object(raw, label)
181
168
  exactKeys(model, allowedModelKeys, label)
182
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(', ')}`)
183
172
  if (seen.has(id)) throw new Error(`${providerID} repeats model ${id}`)
184
173
  seen.add(id)
185
174
  const input = model.input ?? ['text']
@@ -202,6 +191,7 @@ export function normalizeModels(models, providerID) {
202
191
  }
203
192
  return Object.freeze({
204
193
  id,
194
+ type,
205
195
  name: model.name === undefined ? id : text(model.name, `${providerID}.${id}.name`, 256),
206
196
  input: Object.freeze([...new Set(input)]),
207
197
  ...(model.contextWindow === undefined ? {} : { contextWindow: positiveInteger(model.contextWindow, `${providerID}.${id}.contextWindow`) }),
@@ -269,28 +259,29 @@ export function normalizeEnterpriseProfile(raw) {
269
259
  const id = text(source.id, 'profile.id', 64)
270
260
  if (!idPattern.test(id)) throw new Error('profile.id is invalid')
271
261
  const allowInsecureDevelopment = source.allowInsecureDevelopment === true
272
- const allowedInsecureOrigin = source.insecureDevelopmentOrigin === undefined
273
- ? undefined
274
- : insecureDevelopmentOrigin(source.insecureDevelopmentOrigin, 'profile.insecureDevelopmentOrigin')
275
- if (allowedInsecureOrigin !== undefined && !allowInsecureDevelopment) {
276
- throw new Error('profile.insecureDevelopmentOrigin requires allowInsecureDevelopment=true')
277
- }
262
+ if (source.allowInsecureDevelopment !== undefined && typeof source.allowInsecureDevelopment !== 'boolean') throw new Error('profile.allowInsecureDevelopment must be boolean')
263
+ // Accept the obsolete field in old files, but it no longer controls transport.
278
264
  const gateway = source.auth !== undefined
279
265
  if (gateway && source.oidc !== undefined) throw new Error('profile.auth cannot be combined with oidc')
280
266
  let auth
281
267
  if (gateway) {
282
268
  const rawAuth = object(source.auth, 'profile.auth')
283
- exactKeys(rawAuth, new Set(['discoveryUrl', 'expectedIssuer', 'experimentalOidcLlm', 'clientId', 'identityMode']), 'profile.auth')
269
+ exactKeys(rawAuth, new Set(['discoveryUrl', 'expectedIssuer', 'experimentalOidcLlm', 'clientId', 'identityMode', 'additionalScopes']), 'profile.auth')
284
270
  const experimental = rawAuth.experimentalOidcLlm === true
285
271
  if (rawAuth.experimentalOidcLlm !== undefined && typeof rawAuth.experimentalOidcLlm !== 'boolean') throw new Error('profile.auth.experimentalOidcLlm must be boolean')
286
- 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')
287
273
  if (experimental && !['oauth', 'oidc'].includes(rawAuth.identityMode)) throw new Error('profile.auth.identityMode must explicitly select oauth or oidc')
288
- if (experimental && allowedInsecureOrigin && !rawAuth.expectedIssuer) throw new Error('development oidc-llm requires expectedIssuer')
289
- const developmentOrigin = experimental ? allowedInsecureOrigin : undefined
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
+ }
290
280
  auth = Object.freeze({
291
- discoveryUrl: issuerURL(rawAuth.discoveryUrl, 'profile.auth.discoveryUrl', allowInsecureDevelopment, developmentOrigin),
292
- ...(rawAuth.expectedIssuer === undefined ? {} : { expectedIssuer: issuerURL(rawAuth.expectedIssuer, 'profile.auth.expectedIssuer', allowInsecureDevelopment, developmentOrigin) }),
281
+ discoveryUrl: issuerURL(rawAuth.discoveryUrl, 'profile.auth.discoveryUrl', allowInsecureDevelopment),
282
+ ...(rawAuth.expectedIssuer === undefined ? {} : { expectedIssuer: issuerURL(rawAuth.expectedIssuer, 'profile.auth.expectedIssuer', allowInsecureDevelopment) }),
293
283
  ...(experimental ? { experimentalOidcLlm: true, clientId: text(rawAuth.clientId, 'profile.auth.clientId', 256), identityMode: rawAuth.identityMode } : {}),
284
+ ...(rawAuth.additionalScopes === undefined ? {} : { additionalScopes: Object.freeze([...rawAuth.additionalScopes]) }),
294
285
  })
295
286
  if (source.provider?.baseURL !== undefined) throw new Error('profile.auth derives provider.baseURL from validated discovery')
296
287
  if (source.provider?.modelSource !== undefined && source.provider.modelSource !== 'discovery') throw new Error('profile.auth requires discovery modelSource')
@@ -316,11 +307,10 @@ export function normalizeEnterpriseProfile(raw) {
316
307
  displayName: text(source.displayName, 'profile.displayName', 120),
317
308
  organization: text(source.organization ?? source.displayName, 'profile.organization', 120),
318
309
  allowInsecureDevelopment,
319
- ...(allowedInsecureOrigin === undefined ? {} : { insecureDevelopmentOrigin: allowedInsecureOrigin }),
320
310
  nativeInstitutionID: source.nativeInstitutionID === undefined ? id : text(source.nativeInstitutionID, 'profile.nativeInstitutionID', 64),
321
311
  brand: normalizeBrand(source.brand),
322
312
  ...(gateway ? { auth } : { oidc: Object.freeze({
323
- issuer: issuerURL(oidc.issuer, 'profile.oidc.issuer', allowInsecureDevelopment, allowedInsecureOrigin),
313
+ issuer: issuerURL(oidc.issuer, 'profile.oidc.issuer', allowInsecureDevelopment),
324
314
  clientId: text(oidc.clientId, 'profile.oidc.clientId', 256),
325
315
  scopes: Object.freeze([...scopes]),
326
316
  }) }),
@@ -379,12 +369,11 @@ export function loadEnterpriseProfiles(raw = {}, environment = process.env) {
379
369
 
380
370
  export function enterpriseProviderConfig(profiles) {
381
371
  return {
382
- 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, {
383
373
  displayName: profile.provider.displayName,
384
374
  apiKeyEnv: `DSH_GATEWAY_${profile.id.toUpperCase().replace(/-/g, '_')}_ACCESS`,
385
375
  baseURL: profile.provider.baseURL,
386
376
  allowInsecureDevelopment: profile.allowInsecureDevelopment,
387
- ...(profile.insecureDevelopmentOrigin === undefined ? {} : { insecureDevelopmentOrigin: profile.insecureDevelopmentOrigin }),
388
377
  reasoning: profile.provider.reasoning,
389
378
  ...(profile.provider.defaultContextWindow === undefined ? {} : { defaultContextWindow: profile.provider.defaultContextWindow }),
390
379
  ...(profile.provider.defaultMaxTokens === undefined ? {} : { defaultMaxTokens: profile.provider.defaultMaxTokens }),
@@ -394,7 +383,7 @@ export function enterpriseProviderConfig(profiles) {
394
383
  ...(profile.provider.streamIdleTimeoutMs === undefined ? {} : { streamIdleTimeoutMs: profile.provider.streamIdleTimeoutMs }),
395
384
  ...(profile.provider.retryPolicy === undefined ? {} : { retryPolicy: structuredClone(profile.provider.retryPolicy) }),
396
385
  ...(profile.provider.compat === undefined ? {} : { compat: { ...profile.provider.compat } }),
397
- models: profile.provider.models.map(model => ({
386
+ models: profile.provider.models.filter(isLLM).map(({ type, ...model }) => ({
398
387
  ...model,
399
388
  input: [...model.input],
400
389
  ...(model.reasoningEfforts && typeof model.reasoningEfforts === 'object'
@@ -416,7 +405,7 @@ export function publicProfile(profile) {
416
405
  ...(profile.provider ? { provider: {
417
406
  id: profile.provider.id,
418
407
  displayName: profile.provider.displayName,
419
- 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 })),
420
409
  } } : {}),
421
410
  }
422
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
 
@@ -1,3 +1,4 @@
1
+ import { serviceProtocolAllowed } from '../transport.js'
1
2
  const modalities = new Set(['text', 'image'])
2
3
  export const REASONING_EFFORT_ORDER = Object.freeze(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
3
4
  const reasoningEfforts = new Set(REASONING_EFFORT_ORDER)
@@ -12,23 +13,11 @@ function nonEmpty(value, label) {
12
13
  return value.trim()
13
14
  }
14
15
 
15
- function exactDevelopmentOrigin(value, label) {
16
- if (value === undefined) return undefined
16
+ function endpointURL(value, label, allowInsecureDevelopment = false) {
17
17
  const raw = nonEmpty(value, label).replace(/\/+$/, '')
18
18
  const parsed = new URL(raw)
19
- if (parsed.protocol !== 'http:' || parsed.username || parsed.password || parsed.hash || parsed.search || (parsed.pathname !== '/' && parsed.pathname !== '')) {
20
- throw new Error(`${label} must be an exact HTTP origin`)
21
- }
22
- return raw
23
- }
24
-
25
- function endpointURL(value, label, allowInsecureDevelopment = false, insecureDevelopmentOrigin) {
26
- const raw = nonEmpty(value, label).replace(/\/+$/, '')
27
- const parsed = new URL(raw)
28
- const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname)
29
- const allowedOrigin = exactDevelopmentOrigin(insecureDevelopmentOrigin, `${label}.insecureDevelopmentOrigin`)
30
- const permittedHTTP = parsed.protocol === 'http:' && (loopback || (allowInsecureDevelopment === true && parsed.origin === allowedOrigin))
31
- if ((parsed.protocol !== 'https:' && !permittedHTTP) || parsed.username || parsed.password || parsed.hash || parsed.search) {
19
+ if (typeof allowInsecureDevelopment !== 'boolean') throw new Error(`${label}.allowInsecureDevelopment must be boolean`)
20
+ if (!serviceProtocolAllowed(parsed, allowInsecureDevelopment) || parsed.username || parsed.password || parsed.hash || parsed.search) {
32
21
  throw new Error(`${label} must be HTTPS or explicitly allowed development HTTP without credentials, query, or fragment`)
33
22
  }
34
23
  return raw
@@ -100,7 +89,7 @@ export function resolveEnterpriseProfiles(raw = {}) {
100
89
  const route = nonEmpty(provider, 'provider route')
101
90
  if (typeof source !== 'object' || source === null || Array.isArray(source)) throw new Error(`${route} config must be an object`)
102
91
  const displayName = nonEmpty(source.displayName ?? route, `${route}.displayName`)
103
- const baseURL = endpointURL(source.baseURL, `${route}.baseURL`, source.allowInsecureDevelopment, source.insecureDevelopmentOrigin)
92
+ const baseURL = endpointURL(source.baseURL, `${route}.baseURL`, source.allowInsecureDevelopment)
104
93
  const credentialRef = nonEmpty(source.apiKeyEnv, `${route}.apiKeyEnv`)
105
94
  const contextWindow = positiveInteger(source.defaultContextWindow, 262144, `${route}.defaultContextWindow`)
106
95
  const maxTokens = positiveInteger(source.defaultMaxTokens, 32768, `${route}.defaultMaxTokens`)
@@ -185,16 +174,16 @@ export function resolveEnterpriseProfiles(raw = {}) {
185
174
  })
186
175
  }
187
176
 
188
- export function configurableEntries(profiles) {
177
+ export function configurableEntries(profiles, settingsNs = ENTERPRISE_SETTINGS_NAMESPACE) {
189
178
  return [...profiles.values()].map(profile => ({
190
179
  provider: profile.provider,
191
180
  displayName: profile.displayName,
192
- settingsNs: ENTERPRISE_SETTINGS_NAMESPACE,
181
+ settingsNs,
193
182
  settingsPath: ['providers', profile.provider],
194
183
  declared: true,
195
184
  }))
196
185
  }
197
186
 
198
187
  export function settingsBase(rawConfig) {
199
- return { providers: rawConfig.providers ?? {} }
188
+ return { providers: typeof rawConfig.providers?.get === 'function' ? rawConfig.providers.get() : rawConfig.providers ?? {} }
200
189
  }