@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
package/lib/profile.js CHANGED
@@ -1,10 +1,9 @@
1
+ import { serviceProtocolAllowed } from './transport.js'
1
2
  import { readFileSync } from 'node:fs'
2
3
 
3
4
  export const PROFILE_SCHEMA_VERSION = 'dsh-oidc/v1alpha1'
4
- export const DEFAULT_CREDENTIAL_REF = 'EDUWORK_API_KEY'
5
5
  const MAX_PROFILE_BYTES = 512 * 1024
6
6
  const idPattern = /^[a-z][a-z0-9-]{0,63}$/
7
- const credentialPattern = /^[A-Za-z_][A-Za-z0-9_]*$/
8
7
  const colorPattern = /^#[0-9a-fA-F]{6}$/
9
8
  const allowedModalities = new Set(['text', 'image'])
10
9
  const allowedThinkingLevels = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
@@ -15,10 +14,9 @@ const allowedThinkingFormats = new Set([
15
14
  ])
16
15
  const allowedRootKeys = new Set([
17
16
  'schemaVersion', 'id', 'displayName', 'organization', 'nativeInstitutionID',
18
- 'allowInsecureDevelopment', 'insecureDevelopmentOrigin', 'brand', 'oidc', 'keyBinding', 'provider',
17
+ 'allowInsecureDevelopment', 'insecureDevelopmentOrigin', 'brand', 'oidc', 'auth', 'provider',
19
18
  ])
20
19
  const allowedOidcKeys = new Set(['issuer', 'clientId', 'scopes'])
21
- const allowedKeyBindingKeys = new Set(['type', 'baseURL', 'credentialRef'])
22
20
  const allowedBrandKeys = new Set([
23
21
  'productName', 'organizationName', 'mark', 'logoURL', 'primaryColor',
24
22
  'loginTitle', 'loginDescription', 'supportURL',
@@ -55,41 +53,22 @@ function text(value, label, max = 2048) {
55
53
  return value
56
54
  }
57
55
 
58
- function isLoopback(hostname) {
59
- return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'
60
- }
61
-
62
- function insecureDevelopmentOrigin(value, label) {
63
- const raw = text(value, label)
64
- const parsed = new URL(raw)
65
- if (parsed.protocol !== 'http:' || parsed.username || parsed.password || parsed.search || parsed.hash || (parsed.pathname !== '/' && parsed.pathname !== '')) {
66
- throw new Error(`${label} must be an exact HTTP origin without credentials, path, query, or fragment`)
67
- }
68
- return raw.replace(/\/+$/, '')
69
- }
70
-
71
- function endpointProtocolAllowed(parsed, allowInsecure, allowedInsecureOrigin) {
72
- return parsed.protocol === 'https:' || (allowInsecure && parsed.protocol === 'http:' && (
73
- isLoopback(parsed.hostname) || parsed.origin === allowedInsecureOrigin
74
- ))
75
- }
76
-
77
- function issuerURL(value, label, allowInsecure = false, allowedInsecureOrigin) {
56
+ function issuerURL(value, label, allowInsecure = false) {
78
57
  const raw = text(value, label)
79
58
  const parsed = new URL(raw)
80
- const permitted = endpointProtocolAllowed(parsed, allowInsecure, allowedInsecureOrigin)
59
+ const permitted = serviceProtocolAllowed(parsed, allowInsecure)
81
60
  if (!permitted || parsed.username || parsed.password || parsed.search || parsed.hash) {
82
- 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)' : ''}`)
61
+ 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'}`)
83
62
  }
84
63
  return raw
85
64
  }
86
65
 
87
- function exactURL(value, label, allowInsecure = false, allowedInsecureOrigin) {
66
+ function exactURL(value, label, allowInsecure = false) {
88
67
  const raw = text(value, label)
89
68
  const parsed = new URL(raw)
90
- const permitted = endpointProtocolAllowed(parsed, allowInsecure, allowedInsecureOrigin)
69
+ const permitted = serviceProtocolAllowed(parsed, allowInsecure)
91
70
  if (!permitted || parsed.username || parsed.password || parsed.hash || parsed.search) {
92
- 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)' : ''}`)
71
+ 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'}`)
93
72
  }
94
73
  return raw.replace(/\/+$/, '')
95
74
  }
@@ -217,9 +196,6 @@ export function normalizeModels(models, providerID) {
217
196
  }))
218
197
  }
219
198
 
220
- export function legacyCredentialRefs(profile) {
221
- return [...new Set([profile.id, profile.provider?.id].filter(Boolean).map(id => `${id.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_API_KEY`))]
222
- }
223
199
 
224
200
  function normalizeRetryPolicy(value) {
225
201
  const source = object(value, 'profile.provider.retryPolicy')
@@ -275,66 +251,59 @@ export function normalizeEnterpriseProfile(raw) {
275
251
  const id = text(source.id, 'profile.id', 64)
276
252
  if (!idPattern.test(id)) throw new Error('profile.id is invalid')
277
253
  const allowInsecureDevelopment = source.allowInsecureDevelopment === true
278
- const allowedInsecureOrigin = source.insecureDevelopmentOrigin === undefined
279
- ? undefined
280
- : insecureDevelopmentOrigin(source.insecureDevelopmentOrigin, 'profile.insecureDevelopmentOrigin')
281
- if (allowedInsecureOrigin !== undefined && !allowInsecureDevelopment) {
282
- throw new Error('profile.insecureDevelopmentOrigin requires allowInsecureDevelopment=true')
283
- }
284
- const oidc = object(source.oidc, 'profile.oidc')
285
- const identityOnly = source.keyBinding === undefined && source.provider === undefined
286
- if (!identityOnly && (source.keyBinding === undefined || source.provider === undefined)) {
287
- throw new Error('profile.keyBinding and profile.provider must either both be present or both be omitted')
254
+ if (source.allowInsecureDevelopment !== undefined && typeof source.allowInsecureDevelopment !== 'boolean') throw new Error('profile.allowInsecureDevelopment must be boolean')
255
+ // Accept the obsolete field in old files, but it no longer controls transport.
256
+ const gateway = source.auth !== undefined
257
+ if (gateway && source.oidc !== undefined) throw new Error('profile.auth cannot be combined with oidc')
258
+ let auth
259
+ if (gateway) {
260
+ const rawAuth = object(source.auth, 'profile.auth')
261
+ exactKeys(rawAuth, new Set(['discoveryUrl', 'expectedIssuer', 'experimentalOidcLlm', 'clientId', 'identityMode']), 'profile.auth')
262
+ const experimental = rawAuth.experimentalOidcLlm === true
263
+ 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')
265
+ if (experimental && !['oauth', 'oidc'].includes(rawAuth.identityMode)) throw new Error('profile.auth.identityMode must explicitly select oauth or oidc')
266
+ auth = Object.freeze({
267
+ discoveryUrl: issuerURL(rawAuth.discoveryUrl, 'profile.auth.discoveryUrl', allowInsecureDevelopment),
268
+ ...(rawAuth.expectedIssuer === undefined ? {} : { expectedIssuer: issuerURL(rawAuth.expectedIssuer, 'profile.auth.expectedIssuer', allowInsecureDevelopment) }),
269
+ ...(experimental ? { experimentalOidcLlm: true, clientId: text(rawAuth.clientId, 'profile.auth.clientId', 256), identityMode: rawAuth.identityMode } : {}),
270
+ })
271
+ if (source.provider?.baseURL !== undefined) throw new Error('profile.auth derives provider.baseURL from validated discovery')
272
+ if (source.provider?.modelSource !== undefined && source.provider.modelSource !== 'discovery') throw new Error('profile.auth requires discovery modelSource')
288
273
  }
289
- const keyBinding = identityOnly ? {} : object(source.keyBinding, 'profile.keyBinding')
290
- const provider = identityOnly ? {} : object(source.provider, 'profile.provider')
274
+ const oidc = gateway ? {} : object(source.oidc, 'profile.oidc')
275
+ const identityOnly = !gateway
276
+ if (identityOnly && source.provider !== undefined) throw new Error('OIDC identity profiles cannot configure model resources; use auth.discoveryUrl for Access Token model access')
277
+ const provider = gateway ? { id, adapter: 'openai-compatible', ...object(source.provider ?? {}, 'profile.provider'), modelSource: 'discovery' } : {}
291
278
  exactKeys(oidc, allowedOidcKeys, 'profile.oidc')
292
- exactKeys(keyBinding, allowedKeyBindingKeys, 'profile.keyBinding')
293
279
  exactKeys(provider, allowedProviderKeys, 'profile.provider')
294
280
  const providerID = identityOnly ? id : text(provider.id, 'profile.provider.id', 64)
295
281
  if (!idPattern.test(providerID)) throw new Error('profile.provider.id is invalid')
296
- let credentialRef = keyBinding.credentialRef === undefined
297
- ? DEFAULT_CREDENTIAL_REF
298
- : text(keyBinding.credentialRef, 'profile.keyBinding.credentialRef', 128)
299
- if (!credentialPattern.test(credentialRef)) {
300
- throw new Error('profile.keyBinding.credentialRef must be a valid DSH credential reference')
301
- }
302
- if (legacyCredentialRefs({ id, provider: { id: providerID } }).includes(credentialRef)) credentialRef = DEFAULT_CREDENTIAL_REF
303
282
  if (!identityOnly && provider.adapter !== 'openai-compatible') throw new Error(`unsupported provider adapter ${String(provider.adapter)}`)
304
- const protocol = keyBinding.type ?? 'worker-user-center-v1'
305
- if (!['worker-user-center-v1', 'eduwork-resources-v1'].includes(protocol)) throw new Error('profile.keyBinding.type is not supported')
306
- const modelSource = provider.modelSource ?? 'profile'
307
- if (!['profile', 'discovery'].includes(modelSource)) throw new Error('profile.provider.modelSource must be profile or discovery')
283
+ const modelSource = 'discovery'
308
284
  const scopes = oidc.scopes
309
- if (!Array.isArray(scopes) || scopes.length < 2 || scopes.length > 64 || !scopes.includes('openid') || !scopes.includes('profile') || scopes.some(scope => typeof scope !== 'string' || scope.length > 128 || scope.trim() !== scope || scope === '' || /\s/.test(scope))) {
285
+ if (!gateway && (!Array.isArray(scopes) || scopes.length < 2 || scopes.length > 64 || !scopes.includes('openid') || !scopes.includes('profile') || scopes.some(scope => typeof scope !== 'string' || scope.length > 128 || scope.trim() !== scope || scope === '' || /\s/.test(scope)))) {
310
286
  throw new Error('profile.oidc.scopes must contain openid and profile; every scope must be a non-empty token')
311
287
  }
312
- if (new Set(scopes).size !== scopes.length) throw new Error('profile.oidc.scopes contains duplicates')
288
+ if (!gateway && new Set(scopes).size !== scopes.length) throw new Error('profile.oidc.scopes contains duplicates')
313
289
  return Object.freeze({
314
290
  schemaVersion: PROFILE_SCHEMA_VERSION,
315
291
  id,
316
292
  displayName: text(source.displayName, 'profile.displayName', 120),
317
293
  organization: text(source.organization ?? source.displayName, 'profile.organization', 120),
318
294
  allowInsecureDevelopment,
319
- ...(allowedInsecureOrigin === undefined ? {} : { insecureDevelopmentOrigin: allowedInsecureOrigin }),
320
295
  nativeInstitutionID: source.nativeInstitutionID === undefined ? id : text(source.nativeInstitutionID, 'profile.nativeInstitutionID', 64),
321
296
  brand: normalizeBrand(source.brand),
322
- oidc: Object.freeze({
323
- issuer: issuerURL(oidc.issuer, 'profile.oidc.issuer', allowInsecureDevelopment, allowedInsecureOrigin),
297
+ ...(gateway ? { auth } : { oidc: Object.freeze({
298
+ issuer: issuerURL(oidc.issuer, 'profile.oidc.issuer', allowInsecureDevelopment),
324
299
  clientId: text(oidc.clientId, 'profile.oidc.clientId', 256),
325
300
  scopes: Object.freeze([...scopes]),
326
- }),
327
- ...(identityOnly ? {} : { keyBinding: Object.freeze({
328
- type: protocol,
329
- baseURL: exactURL(keyBinding.baseURL, 'profile.keyBinding.baseURL', allowInsecureDevelopment, allowedInsecureOrigin),
330
- providerId: providerID,
331
- credentialRef,
332
- }),
333
- provider: Object.freeze({
301
+ }) }),
302
+ ...(identityOnly ? {} : { provider: Object.freeze({
334
303
  id: providerID,
335
304
  displayName: text(provider.displayName ?? source.displayName, 'profile.provider.displayName', 120),
336
305
  adapter: provider.adapter,
337
- baseURL: exactURL(provider.baseURL, 'profile.provider.baseURL', allowInsecureDevelopment, allowedInsecureOrigin),
306
+ baseURL: '',
338
307
  reasoning: provider.reasoning === undefined
339
308
  ? 'high'
340
309
  : (() => {
@@ -385,12 +354,11 @@ export function loadEnterpriseProfiles(raw = {}, environment = process.env) {
385
354
 
386
355
  export function enterpriseProviderConfig(profiles) {
387
356
  return {
388
- providers: Object.fromEntries([...profiles.values()].filter(profile => profile.provider?.models.length).map(profile => [profile.provider.id, {
357
+ providers: Object.fromEntries([...profiles.values()].filter(profile => profile.provider?.baseURL && profile.provider.models.length).map(profile => [profile.provider.id, {
389
358
  displayName: profile.provider.displayName,
390
- apiKeyEnv: profile.keyBinding.credentialRef,
359
+ apiKeyEnv: `DSH_GATEWAY_${profile.id.toUpperCase().replace(/-/g, '_')}_ACCESS`,
391
360
  baseURL: profile.provider.baseURL,
392
361
  allowInsecureDevelopment: profile.allowInsecureDevelopment,
393
- ...(profile.insecureDevelopmentOrigin === undefined ? {} : { insecureDevelopmentOrigin: profile.insecureDevelopmentOrigin }),
394
362
  reasoning: profile.provider.reasoning,
395
363
  ...(profile.provider.defaultContextWindow === undefined ? {} : { defaultContextWindow: profile.provider.defaultContextWindow }),
396
364
  ...(profile.provider.defaultMaxTokens === undefined ? {} : { defaultMaxTokens: profile.provider.defaultMaxTokens }),
@@ -418,7 +386,7 @@ export function publicProfile(profile) {
418
386
  displayName: profile.displayName,
419
387
  organization: profile.organization,
420
388
  brand: profile.brand,
421
- credentialRef: profile.keyBinding?.credentialRef ?? '',
389
+ credentialRef: '',
422
390
  ...(profile.provider ? { provider: {
423
391
  id: profile.provider.id,
424
392
  displayName: profile.provider.displayName,
@@ -0,0 +1,46 @@
1
+ /** Keep prepared calls, credential resolution and streamed results in one authorization. */
2
+ export class AuthorizationScopedAdapter {
3
+ constructor(inner, storage, createScope) {
4
+ this.inner = inner
5
+ this.storage = storage
6
+ this.createScope = createScope
7
+ }
8
+
9
+ providerInfo(provider) { return this.inner.providerInfo(provider) }
10
+ providerRetryPolicy(provider) { return this.inner.providerRetryPolicy(provider) }
11
+ imageRequestPricing(provider, model) { return this.inner.imageRequestPricing(provider, model) }
12
+ listModels(provider) { return this.inner.listModels(provider) }
13
+ resolveModel(provider, model, signal) { return this.inner.resolveModel(provider, model, signal) }
14
+
15
+ async prepareCall(provider, model, signal) {
16
+ const scope = await this.createScope(provider)
17
+ const prepared = await this.inner.prepareCall(provider, model, signal)
18
+ return { model: prepared.model, stream: options => this.streamPrepared(prepared, options, scope) }
19
+ }
20
+
21
+ async * streamPrepared(prepared, options, scope) {
22
+ if (!scope) { yield* prepared.stream(options); return }
23
+ const lease = scope.open(options.signal)
24
+ let iterator
25
+ try {
26
+ lease.assertCurrent()
27
+ iterator = prepared.stream({ ...options, signal: lease.signal })[Symbol.asyncIterator]()
28
+ for (;;) {
29
+ lease.assertCurrent()
30
+ const next = await this.storage.run(lease, () => iterator.next())
31
+ // Even a transport that buffers or ignores abort must not emit stale data.
32
+ lease.assertCurrent()
33
+ if (next.done) return
34
+ yield next.value
35
+ }
36
+ } finally {
37
+ try { await this.storage.run(lease, () => iterator?.return?.()) }
38
+ finally { lease.close() }
39
+ }
40
+ }
41
+
42
+ async * stream(options) {
43
+ const prepared = await this.prepareCall(options.provider, options.model, options.signal)
44
+ yield* prepared.stream(options)
45
+ }
46
+ }
@@ -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`)
@@ -1,3 +1,4 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
1
2
  import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
2
3
  import { LlmError, assertUsableApiKey, resolveImageAttachmentAccess, resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
3
4
  import { Config as PiAiConfig, PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
@@ -10,6 +11,7 @@ import {
10
11
  } from './core.js'
11
12
  import { TransformingEnterpriseAdapter } from './transform-adapter.js'
12
13
  import { EnterpriseModelTransforms } from './transforms.js'
14
+ import { AuthorizationScopedAdapter } from './authorization-scope.js'
13
15
 
14
16
  export const name = 'dsh-oidc-provider'
15
17
  export const inject = ['llm']
@@ -108,6 +110,7 @@ export function resolveEnterpriseImageAccess(ctx, attachments, ref) {
108
110
  }
109
111
 
110
112
  export function apply(ctx, rawConfig = {}) {
113
+ const callAuthorization = new AsyncLocalStorage()
111
114
  const transforms = new EnterpriseModelTransforms(ctx)
112
115
  let current = () => settingsBase(rawConfig)
113
116
  let profiles = profilesFrom(current())
@@ -121,7 +124,10 @@ export function apply(ctx, rawConfig = {}) {
121
124
  }
122
125
  const ref = profile.apiKeyEnv
123
126
  const credentials = ctx.get('credentials')
124
- const hit = typeof rawConfig.resolveCredential === 'function'
127
+ const lease = callAuthorization.getStore()
128
+ const hit = lease
129
+ ? (await lease.resolveCredential(provider))?.value
130
+ : typeof rawConfig.resolveCredential === 'function'
125
131
  ? (await rawConfig.resolveCredential(provider))?.value
126
132
  : credentials === undefined
127
133
  ? launchEnvironmentOf(ctx).get(ref)?.value
@@ -133,9 +139,12 @@ export function apply(ctx, rawConfig = {}) {
133
139
  resolveImageAccess: (attachments, ref) => resolveEnterpriseImageAccess(ctx, attachments, ref),
134
140
  })
135
141
 
136
- const adapter = new TransformingEnterpriseAdapter(baseAdapter, transforms, (provider, model) => (
142
+ const transforming = new TransformingEnterpriseAdapter(baseAdapter, transforms, (provider, model) => (
137
143
  profiles.get(provider)?.modelPolicies.get(model) ?? { reasoning: true, supportsReasoningEffort: true }
138
144
  ))
145
+ const adapter = typeof rawConfig.createAuthorizationScope === 'function'
146
+ ? new AuthorizationScopedAdapter(transforming, callAuthorization, rawConfig.createAuthorizationScope)
147
+ : transforming
139
148
  let registration
140
149
  let directory
141
150
  const replaceProfiles = next => {
@@ -0,0 +1,5 @@
1
+ // One transport rule for configured and discovered service URLs.
2
+ // Callback binding, issuer identity and protocol-specific origin checks are separate.
3
+ export function serviceProtocolAllowed(url, allowInsecureDevelopment = false) {
4
+ return url.protocol === 'https:' || (allowInsecureDevelopment === true && url.protocol === 'http:')
5
+ }
@@ -3,7 +3,7 @@ import { z } from 'zod'
3
3
  const pkg = '@eduwork/dsh-oidc'
4
4
  export const profileIdSchema = z.string().min(1).max(64)
5
5
  export const loginIdSchema = z.string().regex(/^[A-Za-z0-9_-]{32}$/)
6
- export const reconcileOptionsSchema = z.object({ allowProvision: z.boolean().optional() }).strict()
6
+ export const reconcileOptionsSchema = z.object({}).strict()
7
7
  export const baseURLSchema = z.string().min(1).max(2048)
8
8
  export const modelCatalogModeSchema = z.enum(['discovery', 'manual'])
9
9
  export const configurationTargetSchema = z.enum(['config', 'examples'])
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@eduwork/dsh-oidc",
3
- "version": "0.2.4",
4
- "description": "Standards-based OIDC identity, enterprise API-key binding, declarative branding, and OpenAI-compatible model integration for DeepSeek Harness",
3
+ "version": "0.3.0-dev.2",
4
+ "description": "Standards-based OIDC identity, token-authorized model access, declarative branding, and OpenAI-compatible model integration for DeepSeek Harness",
5
5
  "keywords": [
6
6
  "deepseek-harness",
7
7
  "dsh",