@eduwork/dsh-oidc 0.1.0-alpha.11

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 (50) hide show
  1. package/CHANGELOG.en.md +120 -0
  2. package/CHANGELOG.md +120 -0
  3. package/LICENSE +21 -0
  4. package/README.en.md +184 -0
  5. package/README.md +143 -0
  6. package/THIRD_PARTY_NOTICES.en.md +51 -0
  7. package/THIRD_PARTY_NOTICES.md +53 -0
  8. package/cordis.patch.yml +8 -0
  9. package/docs/EDUWORK-MIGRATION.en.md +23 -0
  10. package/docs/EDUWORK-MIGRATION.md +23 -0
  11. package/docs/architecture.en.md +125 -0
  12. package/docs/architecture.md +125 -0
  13. package/docs/compatibility.en.md +61 -0
  14. package/docs/compatibility.md +61 -0
  15. package/docs/decisions/0001-one-plugin-composition-boundary.en.md +34 -0
  16. package/docs/decisions/0001-one-plugin-composition-boundary.md +34 -0
  17. package/docs/dsh-integration.en.md +171 -0
  18. package/docs/dsh-integration.md +171 -0
  19. package/docs/ecnu-reference.en.md +66 -0
  20. package/docs/ecnu-reference.md +66 -0
  21. package/docs/enterprise-profile.en.md +159 -0
  22. package/docs/enterprise-profile.md +159 -0
  23. package/docs/getting-started.en.md +137 -0
  24. package/docs/getting-started.md +239 -0
  25. package/docs/key-binding-protocol.en.md +227 -0
  26. package/docs/key-binding-protocol.md +227 -0
  27. package/docs/oidc-interoperability.en.md +100 -0
  28. package/docs/oidc-interoperability.md +100 -0
  29. package/docs/release-checklist.en.md +60 -0
  30. package/docs/release-checklist.md +60 -0
  31. package/docs/security-model.en.md +138 -0
  32. package/docs/security-model.md +138 -0
  33. package/docs/server-integration-contract.en.md +706 -0
  34. package/docs/server-integration-contract.md +706 -0
  35. package/examples/ecnu.enterprise-profile.example.json +81 -0
  36. package/examples/enterprise-profile.example.json +77 -0
  37. package/lib/client.js +5855 -0
  38. package/lib/index.js +59 -0
  39. package/lib/oidc.js +649 -0
  40. package/lib/profile.js +371 -0
  41. package/lib/provider/core.js +138 -0
  42. package/lib/provider/index.js +156 -0
  43. package/lib/provider/transform-adapter.js +65 -0
  44. package/lib/provider/transforms.js +59 -0
  45. package/lib/typert-schemas.js +84 -0
  46. package/lib/typert.host.js +38 -0
  47. package/lib/typert.remote-client.js +37 -0
  48. package/package.json +120 -0
  49. package/protocol/openapi.yaml +257 -0
  50. package/schema/enterprise-profile.v1alpha1.schema.json +174 -0
package/lib/oidc.js ADDED
@@ -0,0 +1,649 @@
1
+ import { createHash, createPublicKey, randomBytes, randomUUID, timingSafeEqual, verify as verifySignature } from 'node:crypto'
2
+
3
+ const FLOW_TTL_MS = 10 * 60 * 1000
4
+ const MAX_PENDING_FLOWS = 32
5
+ const CLOCK_SKEW_SECONDS = 60
6
+ const ACCESS_REFRESH_SECONDS = 90
7
+ const MAX_RESPONSE_BYTES = 1024 * 1024
8
+ export const OIDC_CALLBACK_PATH = '/oauth/callback'
9
+
10
+ function base64url(bytes) {
11
+ return Buffer.from(bytes).toString('base64url')
12
+ }
13
+
14
+ function decodePart(value, label) {
15
+ try { return JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) }
16
+ catch { throw publicError('oidc_id_token_invalid', `${label} is not valid JSON`) }
17
+ }
18
+
19
+ function publicError(code, message, cause) {
20
+ const error = new Error(message, cause === undefined ? undefined : { cause })
21
+ error.code = code
22
+ return error
23
+ }
24
+
25
+ async function responseJSON(response, stage) {
26
+ const declared = Number(response.headers?.get?.('content-length'))
27
+ if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) throw publicError(stage, `${stage}: response exceeds ${MAX_RESPONSE_BYTES} bytes`)
28
+ let bytes
29
+ try { bytes = Buffer.from(await response.arrayBuffer()) }
30
+ catch (cause) { throw publicError(stage, `${stage}: unable to read response`, cause) }
31
+ if (bytes.byteLength > MAX_RESPONSE_BYTES) throw publicError(stage, `${stage}: response exceeds ${MAX_RESPONSE_BYTES} bytes`)
32
+ let value
33
+ try { value = JSON.parse(bytes.toString('utf8')) }
34
+ catch (cause) { throw publicError(stage, `${stage}: response is not JSON`, cause) }
35
+ if (!response.ok) {
36
+ const detail = typeof value?.error === 'string' ? value.error : `HTTP ${response.status}`
37
+ const error = publicError(stage, `${stage}: ${detail}`)
38
+ error.status = response.status
39
+ error.oauthError = value?.error
40
+ throw error
41
+ }
42
+ return value
43
+ }
44
+
45
+ function isLoopback(hostname) {
46
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'
47
+ }
48
+
49
+ /** OIDC metadata endpoints may use origins distinct from the issuer over HTTPS. */
50
+ function discoveredEndpoint(value, profile, label, optional = false) {
51
+ if (optional && (value === undefined || value === '')) return ''
52
+ if (typeof value !== 'string' || value === '') throw publicError('oidc_discovery_invalid', `OIDC discovery is missing ${label}`)
53
+ let endpoint
54
+ try { endpoint = new URL(value) }
55
+ catch (cause) { throw publicError('oidc_discovery_invalid', `OIDC ${label} is not an absolute URL`, cause) }
56
+ const issuerURL = new URL(profile.oidc.issuer)
57
+ const secure = endpoint.protocol === 'https:'
58
+ const localDevelopment = issuerURL.protocol === 'http:' && isLoopback(issuerURL.hostname)
59
+ && endpoint.protocol === 'http:' && isLoopback(endpoint.hostname)
60
+ const explicitlyAllowedDevelopment = profile.allowInsecureDevelopment === true
61
+ && typeof profile.insecureDevelopmentOrigin === 'string'
62
+ && issuerURL.origin === profile.insecureDevelopmentOrigin
63
+ && endpoint.protocol === 'http:'
64
+ && endpoint.origin === profile.insecureDevelopmentOrigin
65
+ if ((!secure && !localDevelopment && !explicitlyAllowedDevelopment) || endpoint.username || endpoint.password || endpoint.hash) {
66
+ throw publicError('oidc_discovery_invalid', `OIDC ${label} must be HTTPS, loopback HTTP, or the profile's exact development HTTP origin`)
67
+ }
68
+ return endpoint.toString()
69
+ }
70
+
71
+ function sessionRef(profile) {
72
+ return `DSH_OIDC_${profile.id.toUpperCase().replace(/-/g, '_')}_SESSION`
73
+ }
74
+
75
+ function nowSeconds(now) {
76
+ return Math.floor(now() / 1000)
77
+ }
78
+
79
+ function presentationText(value) {
80
+ return typeof value === 'string' ? value.trim() : ''
81
+ }
82
+
83
+ function account(profile, session, credentialReady, credentialState = '') {
84
+ return {
85
+ profileID: profile.id,
86
+ displayName: profile.displayName,
87
+ organization: profile.organization,
88
+ state: session ? (credentialReady ? 'connected' : 'authenticated') : 'signed_out',
89
+ ...(session?.identity?.name ? { userName: session.identity.name } : {}),
90
+ ...(session?.identity?.affiliation ? { affiliation: session.identity.affiliation } : {}),
91
+ ...(session?.expiresAt ? { accessExpiresAt: new Date(session.expiresAt * 1000).toISOString() } : {}),
92
+ credentialRef: profile.keyBinding.credentialRef,
93
+ credentialReady,
94
+ ...(credentialState ? { credentialState } : {}),
95
+ capabilities: Array.isArray(session?.capabilities) ? session.capabilities : [],
96
+ }
97
+ }
98
+
99
+ function runtimeModel(model) {
100
+ return {
101
+ id: String(model?.id ?? ''),
102
+ ...(model?.name ? { name: String(model.name) } : {}),
103
+ ...(model?.upstreamModelID ? { upstreamModelID: String(model.upstreamModelID) } : {}),
104
+ ...(model?.contextWindow ? { contextWindow: model.contextWindow } : {}),
105
+ ...(model?.maxTokens ? { maxTokens: model.maxTokens } : {}),
106
+ ...(Array.isArray(model?.input) ? { input: [...model.input] } : {}),
107
+ ...(typeof model?.reasoning === 'boolean' ? { reasoning: model.reasoning } : {}),
108
+ ...(model?.reasoningEfforts && typeof model.reasoningEfforts === 'object' ? { reasoningEfforts: { ...model.reasoningEfforts } } : {}),
109
+ ...(model?.compat && typeof model.compat === 'object' ? { compat: { ...model.compat } } : {}),
110
+ }
111
+ }
112
+
113
+ function runtimeProjection(runtime = {}, fallback = {}) {
114
+ return {
115
+ displayName: String(runtime.displayName ?? fallback.displayName ?? ''),
116
+ ...(runtime.modelSource ? { modelSource: String(runtime.modelSource) } : {}),
117
+ ...(runtime.reasoning ? { reasoning: String(runtime.reasoning) } : {}),
118
+ ...(runtime.defaultContextWindow ? { defaultContextWindow: runtime.defaultContextWindow } : {}),
119
+ ...(runtime.defaultMaxTokens ? { defaultMaxTokens: runtime.defaultMaxTokens } : {}),
120
+ ...(runtime.compat && typeof runtime.compat === 'object' ? { compat: { ...runtime.compat } } : {}),
121
+ models: Array.isArray(runtime.models) ? runtime.models.map(runtimeModel) : [],
122
+ }
123
+ }
124
+
125
+ function profileManagement(profiles) {
126
+ const rows = [...profiles.values()].map(profile => ({
127
+ id: profile.id, displayName: profile.displayName, organization: profile.organization,
128
+ baseURL: profile.provider.baseURL, builtIn: true, configured: true, enabled: true,
129
+ providerID: profile.provider.id,
130
+ runtime: runtimeProjection({ ...profile.provider, modelSource: 'profile' }, profile.provider),
131
+ }))
132
+ return {
133
+ schemaVersion: 'dsh-oidc/management/v1alpha1', mode: 'profile',
134
+ activeProfileID: rows[0]?.id ?? '', restartRequired: false,
135
+ capabilities: { manageProfiles: false, manageModels: false, restart: false }, profiles: rows,
136
+ }
137
+ }
138
+
139
+ function unsupportedManagement() {
140
+ throw publicError('oidc_management_unsupported', 'this dsh-oidc backend does not support mutable enterprise configuration')
141
+ }
142
+
143
+ function loopbackBaseURL(config, webServer) {
144
+ if (config.publicBaseURL !== undefined) {
145
+ throw new Error('dsh-oidc Web callback origin is fixed to 127.0.0.1 and does not accept publicBaseURL')
146
+ }
147
+ if (webServer?.host !== '127.0.0.1') {
148
+ throw new Error('dsh-oidc Web backend requires the DSH WebServer host to be exactly 127.0.0.1')
149
+ }
150
+ const port = Number(webServer.port)
151
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
152
+ throw new Error('dsh-oidc Web backend requires a valid DSH WebServer port')
153
+ }
154
+ return `http://127.0.0.1:${port}`
155
+ }
156
+
157
+ function normalizeReturnPath(raw, callbackBaseURL) {
158
+ if (raw === undefined || raw === '') return '/'
159
+ if (typeof raw !== 'string' || !raw.startsWith('/') || raw.startsWith('//')) throw new Error('dsh-oidc returnPath must be a same-origin absolute path')
160
+ const target = new URL(raw, callbackBaseURL)
161
+ if (target.origin !== new URL(callbackBaseURL).origin || target.username || target.password || target.hash) {
162
+ throw new Error('dsh-oidc returnPath must stay on the loopback callback origin')
163
+ }
164
+ return `${target.pathname}${target.search}`
165
+ }
166
+
167
+ function singleQueryParameter(url, name, required = false) {
168
+ const values = url.searchParams.getAll(name)
169
+ if (values.length > 1) throw publicError('oidc_callback_invalid', `OIDC callback repeats ${name}`)
170
+ const value = values[0] ?? ''
171
+ if (required && value === '') throw publicError('oidc_callback_invalid', `OIDC callback has no ${name}`)
172
+ return value
173
+ }
174
+
175
+ function discoveryURL(issuer) {
176
+ return `${issuer.replace(/\/+$/, '')}/.well-known/openid-configuration`
177
+ }
178
+
179
+ function equalText(left, right) {
180
+ const a = Buffer.from(String(left))
181
+ const b = Buffer.from(String(right))
182
+ return a.byteLength === b.byteLength && timingSafeEqual(a, b)
183
+ }
184
+
185
+ function verifyAccessTokenHash(accessToken, claim) {
186
+ const expected = base64url(createHash('sha256').update(accessToken).digest().subarray(0, 16))
187
+ return equalText(expected, claim)
188
+ }
189
+
190
+ export class WebOidcBackend {
191
+ constructor(ctx, profiles, config = {}, options = {}) {
192
+ this.ctx = ctx
193
+ this.profiles = profiles
194
+ this.config = config
195
+ this.fetch = options.fetch ?? fetch
196
+ this.now = options.now ?? Date.now
197
+ this.flows = new Map()
198
+ this.discovery = new Map()
199
+ this.callbackBaseURL = loopbackBaseURL(config, ctx.webServer)
200
+ this.returnPath = normalizeReturnPath(config.returnPath, this.callbackBaseURL)
201
+ this.callbackPath = OIDC_CALLBACK_PATH
202
+ this.redirectURI = `${this.callbackBaseURL}${this.callbackPath}`
203
+ ctx.effect(() => ctx.webServer.register({
204
+ kind: 'exact',
205
+ path: this.callbackPath,
206
+ handler: (request, response) => this.callback(request, response),
207
+ }), 'dsh-oidc: web callback')
208
+ }
209
+
210
+ profile(id) {
211
+ const profile = this.profiles.get(id)
212
+ if (!profile) throw publicError('oidc_profile_unknown', `unknown OIDC profile ${id}`)
213
+ return profile
214
+ }
215
+
216
+ async discover(profile) {
217
+ const cached = this.discovery.get(profile.id)
218
+ if (cached) return cached
219
+ let response
220
+ try {
221
+ response = await this.fetch(discoveryURL(profile.oidc.issuer), {
222
+ headers: { accept: 'application/json' }, signal: AbortSignal.timeout(20_000),
223
+ })
224
+ } catch (cause) {
225
+ throw publicError('oidc_discovery_failed', 'unable to read OIDC discovery metadata', cause)
226
+ }
227
+ const raw = await responseJSON(response, 'oidc_discovery_failed')
228
+ if (raw.issuer !== profile.oidc.issuer) throw publicError('oidc_discovery_invalid', 'OIDC issuer does not exactly match the configured profile')
229
+ if (!Array.isArray(raw.code_challenge_methods_supported) || !raw.code_challenge_methods_supported.includes('S256')) {
230
+ throw publicError('oidc_discovery_invalid', 'OIDC provider does not advertise PKCE S256')
231
+ }
232
+ if (Array.isArray(raw.id_token_signing_alg_values_supported) && !raw.id_token_signing_alg_values_supported.includes('RS256')) {
233
+ throw publicError('oidc_discovery_invalid', 'OIDC provider does not advertise RS256 ID Tokens')
234
+ }
235
+ const result = Object.freeze({
236
+ issuer: raw.issuer,
237
+ authorizationEndpoint: discoveredEndpoint(raw.authorization_endpoint, profile, 'authorization_endpoint'),
238
+ tokenEndpoint: discoveredEndpoint(raw.token_endpoint, profile, 'token_endpoint'),
239
+ userInfoEndpoint: discoveredEndpoint(raw.userinfo_endpoint, profile, 'userinfo_endpoint'),
240
+ jwksURI: discoveredEndpoint(raw.jwks_uri, profile, 'jwks_uri'),
241
+ revocationEndpoint: discoveredEndpoint(raw.revocation_endpoint, profile, 'revocation_endpoint', true),
242
+ })
243
+ this.discovery.set(profile.id, result)
244
+ return result
245
+ }
246
+
247
+ async begin(profileID) {
248
+ const profile = this.profile(profileID)
249
+ const discovery = await this.discover(profile)
250
+ this.pruneFlows()
251
+ if (this.flows.size >= MAX_PENDING_FLOWS) throw publicError('oidc_flow_limit', 'too many pending OIDC login attempts')
252
+ const state = base64url(randomBytes(32))
253
+ const nonce = base64url(randomBytes(32))
254
+ const verifier = base64url(randomBytes(48))
255
+ const challenge = base64url(createHash('sha256').update(verifier).digest())
256
+ this.flows.set(state, { profileID, nonce, verifier, createdAt: this.now() })
257
+ const target = new URL(discovery.authorizationEndpoint)
258
+ for (const [key, value] of Object.entries({
259
+ response_type: 'code', client_id: profile.oidc.clientId, redirect_uri: this.redirectURI,
260
+ scope: profile.oidc.scopes.join(' '), state, nonce, code_challenge: challenge,
261
+ code_challenge_method: 'S256',
262
+ })) target.searchParams.set(key, value)
263
+ return { mode: 'redirect', authorizationURL: target.toString() }
264
+ }
265
+
266
+ pruneFlows() {
267
+ const threshold = this.now() - FLOW_TTL_MS
268
+ for (const [state, flow] of this.flows) if (flow.createdAt < threshold) this.flows.delete(state)
269
+ }
270
+
271
+ async callback(request, response) {
272
+ const requested = new URL(request.url ?? this.callbackPath, this.callbackBaseURL)
273
+ let outcome = 'error'
274
+ let profileID = ''
275
+ try {
276
+ if (requested.origin !== new URL(this.callbackBaseURL).origin || requested.pathname !== this.callbackPath) throw publicError('oidc_callback_invalid', 'OIDC callback URL is invalid')
277
+ const state = singleQueryParameter(requested, 'state', true)
278
+ const flow = this.flows.get(state)
279
+ this.flows.delete(state)
280
+ if (!flow || flow.createdAt < this.now() - FLOW_TTL_MS) throw publicError('oidc_callback_invalid', 'OIDC login state is missing or expired')
281
+ profileID = flow.profileID
282
+ const profile = this.profile(flow.profileID)
283
+ const oauthError = singleQueryParameter(requested, 'error')
284
+ if (oauthError) throw publicError('oidc_authorization_rejected', `OIDC authorization returned ${oauthError}`)
285
+ const code = singleQueryParameter(requested, 'code', true)
286
+ const responseIssuer = singleQueryParameter(requested, 'iss')
287
+ if (responseIssuer && !equalText(responseIssuer, profile.oidc.issuer)) throw publicError('oidc_callback_invalid', 'OIDC authorization response issuer is invalid')
288
+ const discovery = await this.discover(profile)
289
+ const session = await this.exchangeCode(profile, discovery, code, flow.verifier, flow.nonce)
290
+ await this.saveSession(profile, session)
291
+ const status = await this.reconcile(profile.id, { allowProvision: false })
292
+ outcome = status.credentialReady ? 'connected' : 'credential-required'
293
+ } catch (cause) {
294
+ this.ctx.logger.warn(cause instanceof Error ? cause : new Error(String(cause)))
295
+ outcome = cause?.code ?? 'oidc_callback_failed'
296
+ }
297
+ const target = new URL(this.returnPath, this.callbackBaseURL)
298
+ target.searchParams.set('dsh_oidc', outcome)
299
+ if (profileID) target.searchParams.set('profile', profileID)
300
+ response.writeHead(302, { location: target.toString(), 'cache-control': 'no-store' })
301
+ response.end()
302
+ }
303
+
304
+ async exchangeCode(profile, discovery, code, verifier, nonce) {
305
+ const body = new URLSearchParams({
306
+ grant_type: 'authorization_code', client_id: profile.oidc.clientId,
307
+ code, redirect_uri: this.redirectURI, code_verifier: verifier,
308
+ })
309
+ const response = await this.fetch(discovery.tokenEndpoint, {
310
+ method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
311
+ body, signal: AbortSignal.timeout(20_000),
312
+ })
313
+ const token = await responseJSON(response, 'oidc_token_request_failed')
314
+ if (typeof token.access_token !== 'string' || typeof token.id_token !== 'string' || token.token_type?.toLowerCase() !== 'bearer') {
315
+ throw publicError('oidc_token_invalid', 'OIDC token response is incomplete')
316
+ }
317
+ const expiresIn = token.expires_in === undefined ? 3600 : Number(token.expires_in)
318
+ if (!Number.isFinite(expiresIn) || expiresIn <= 0) throw publicError('oidc_token_invalid', 'OIDC token expiry is invalid')
319
+ const claims = await this.verifyIDToken(profile, discovery, token.id_token, nonce, token.access_token)
320
+ const userInfo = await this.userInfo(discovery.userInfoEndpoint, token.access_token)
321
+ if (!equalText(userInfo.sub, claims.sub)) throw publicError('oidc_userinfo_invalid', 'OIDC UserInfo subject does not match ID Token')
322
+ const userName = presentationText(userInfo.name) || presentationText(userInfo.sub)
323
+ const affiliation = presentationText(userInfo.affiliation)
324
+ return {
325
+ issuer: profile.oidc.issuer,
326
+ clientId: profile.oidc.clientId,
327
+ accessToken: token.access_token,
328
+ refreshToken: typeof token.refresh_token === 'string' ? token.refresh_token : '',
329
+ expiresAt: nowSeconds(this.now) + Math.max(60, expiresIn),
330
+ identity: {
331
+ sub: claims.sub,
332
+ name: userName,
333
+ ...(affiliation ? { affiliation } : {}),
334
+ },
335
+ capabilities: [],
336
+ }
337
+ }
338
+
339
+ async verifyIDToken(profile, discovery, raw, expectedNonce, accessToken) {
340
+ const parts = raw.split('.')
341
+ if (parts.length !== 3) throw publicError('oidc_id_token_invalid', 'OIDC ID Token is malformed')
342
+ const header = decodePart(parts[0], 'ID Token header')
343
+ const claims = decodePart(parts[1], 'ID Token claims')
344
+ if (header.alg !== 'RS256' || typeof header.kid !== 'string' || header.kid === '') throw publicError('oidc_id_token_invalid', 'OIDC ID Token must use RS256 with kid')
345
+ const keysResponse = await this.fetch(discovery.jwksURI, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(20_000) })
346
+ const jwks = await responseJSON(keysResponse, 'oidc_jwks_failed')
347
+ const candidates = Array.isArray(jwks.keys) ? jwks.keys.filter(key => (
348
+ key.kid === header.kid && key.kty === 'RSA' && (key.use === undefined || key.use === 'sig')
349
+ && (key.alg === undefined || key.alg === 'RS256')
350
+ )) : []
351
+ if (candidates.length !== 1) throw publicError('oidc_id_token_invalid', 'OIDC signing key was not uniquely identified')
352
+ let key
353
+ try { key = createPublicKey({ key: candidates[0], format: 'jwk' }) }
354
+ catch (cause) { throw publicError('oidc_id_token_invalid', 'OIDC signing key is invalid', cause) }
355
+ const valid = verifySignature('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), key, Buffer.from(parts[2], 'base64url'))
356
+ if (!valid) throw publicError('oidc_id_token_invalid', 'OIDC ID Token signature is invalid')
357
+ const now = nowSeconds(this.now)
358
+ const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud]
359
+ const audienceValid = audience.includes(profile.oidc.clientId)
360
+ && (audience.length === 1 || claims.azp === profile.oidc.clientId)
361
+ if (claims.iss !== profile.oidc.issuer || !audienceValid || typeof claims.sub !== 'string' || claims.sub === '' || !equalText(claims.nonce, expectedNonce)) {
362
+ throw publicError('oidc_id_token_invalid', 'OIDC ID Token identity claims are invalid')
363
+ }
364
+ if (!Number.isFinite(claims.exp) || !Number.isFinite(claims.iat)
365
+ || claims.exp < now - CLOCK_SKEW_SECONDS || claims.iat > now + CLOCK_SKEW_SECONDS
366
+ || (claims.nbf !== undefined && (!Number.isFinite(claims.nbf) || claims.nbf > now + CLOCK_SKEW_SECONDS))) {
367
+ throw publicError('oidc_id_token_invalid', 'OIDC ID Token time claims are invalid')
368
+ }
369
+ if (claims.at_hash !== undefined && (typeof claims.at_hash !== 'string' || !verifyAccessTokenHash(accessToken, claims.at_hash))) {
370
+ throw publicError('oidc_id_token_invalid', 'OIDC ID Token access-token hash is invalid')
371
+ }
372
+ return claims
373
+ }
374
+
375
+ async userInfo(endpoint, accessToken) {
376
+ const response = await this.fetch(endpoint, {
377
+ headers: { authorization: `Bearer ${accessToken}`, accept: 'application/json' }, signal: AbortSignal.timeout(20_000),
378
+ })
379
+ const result = await responseJSON(response, 'oidc_userinfo_failed')
380
+ if (typeof result.sub !== 'string' || result.sub === '') throw publicError('oidc_userinfo_invalid', 'OIDC UserInfo has no subject')
381
+ return result
382
+ }
383
+
384
+ async loadSession(profile) {
385
+ const record = await this.ctx.credentials.resolve(sessionRef(profile))
386
+ if (typeof record?.value !== 'string') return undefined
387
+ try {
388
+ const session = JSON.parse(record.value)
389
+ if (session.issuer !== profile.oidc.issuer || session.clientId !== profile.oidc.clientId
390
+ || typeof session.accessToken !== 'string' || !Number.isFinite(session.expiresAt)
391
+ || typeof session.identity?.sub !== 'string') throw new Error('session binding mismatch')
392
+ return session
393
+ } catch {
394
+ await this.ctx.credentials.unset(sessionRef(profile))
395
+ return undefined
396
+ }
397
+ }
398
+
399
+ saveSession(profile, session) {
400
+ return this.ctx.credentials.set(sessionRef(profile), JSON.stringify(session))
401
+ }
402
+
403
+ async refresh(profile, session) {
404
+ if (!session.refreshToken) throw publicError('oidc_login_required', 'OIDC session cannot be refreshed')
405
+ const discovery = await this.discover(profile)
406
+ const response = await this.fetch(discovery.tokenEndpoint, {
407
+ method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
408
+ body: new URLSearchParams({ grant_type: 'refresh_token', client_id: profile.oidc.clientId, refresh_token: session.refreshToken }),
409
+ signal: AbortSignal.timeout(20_000),
410
+ })
411
+ let token
412
+ try { token = await responseJSON(response, 'oidc_refresh_failed') }
413
+ catch (cause) {
414
+ if (cause.oauthError === 'invalid_grant') {
415
+ await Promise.all([
416
+ this.ctx.credentials.unset(sessionRef(profile)),
417
+ this.ctx.credentials.unset(profile.keyBinding.credentialRef),
418
+ ])
419
+ throw publicError('oidc_login_required', 'OIDC refresh token is no longer valid', cause)
420
+ }
421
+ throw cause
422
+ }
423
+ if (typeof token.access_token !== 'string' || token.token_type?.toLowerCase() !== 'bearer') {
424
+ throw publicError('oidc_refresh_failed', 'OIDC refresh response has no Bearer access token')
425
+ }
426
+ const expiresIn = token.expires_in === undefined ? 3600 : Number(token.expires_in)
427
+ if (!Number.isFinite(expiresIn) || expiresIn <= 0) throw publicError('oidc_refresh_failed', 'OIDC refresh expiry is invalid')
428
+ const next = {
429
+ ...session,
430
+ accessToken: token.access_token,
431
+ refreshToken: typeof token.refresh_token === 'string' ? token.refresh_token : session.refreshToken,
432
+ expiresAt: nowSeconds(this.now) + Math.max(60, expiresIn),
433
+ }
434
+ await this.saveSession(profile, next)
435
+ return next
436
+ }
437
+
438
+ async activeSession(profile) {
439
+ let session = await this.loadSession(profile)
440
+ if (!session) return undefined
441
+ if (session.expiresAt <= nowSeconds(this.now) + ACCESS_REFRESH_SECONDS) {
442
+ if (!session.refreshToken) {
443
+ await Promise.all([
444
+ this.ctx.credentials.unset(sessionRef(profile)),
445
+ this.ctx.credentials.unset(profile.keyBinding.credentialRef),
446
+ ])
447
+ return undefined
448
+ }
449
+ session = await this.refresh(profile, session)
450
+ }
451
+ return session
452
+ }
453
+
454
+ async status(profileID) {
455
+ const profile = this.profile(profileID)
456
+ const [session, credential] = await Promise.all([
457
+ this.loadSession(profile), this.ctx.credentials.resolve(profile.keyBinding.credentialRef),
458
+ ])
459
+ return account(profile, session, typeof credential?.value === 'string' && credential.value !== '')
460
+ }
461
+
462
+ async authorized(profile, session, endpoint, init = {}) {
463
+ const request = current => this.fetch(endpoint, {
464
+ ...init,
465
+ headers: { ...(init.headers ?? {}), authorization: `Bearer ${current.accessToken}`, accept: 'application/json' },
466
+ signal: init.signal ?? AbortSignal.timeout(20_000),
467
+ })
468
+ let current = session
469
+ let response = await request(current)
470
+ if (response.status === 401 && current.refreshToken) {
471
+ current = await this.refresh(profile, current)
472
+ response = await request(current)
473
+ }
474
+ return { response, session: current }
475
+ }
476
+
477
+ async reconcile(profileID, options = {}) {
478
+ const profile = this.profile(profileID)
479
+ let session = await this.activeSession(profile)
480
+ if (!session) return account(profile, undefined, false)
481
+ const bootstrapCall = await this.authorized(profile, session, `${profile.keyBinding.baseURL}/bootstrap`)
482
+ session = bootstrapCall.session
483
+ const bootstrap = await responseJSON(bootstrapCall.response, 'oidc_binding_bootstrap_failed')
484
+ if (bootstrap.provider?.id !== profile.keyBinding.providerId) {
485
+ throw publicError('oidc_binding_invalid', 'key binding provider does not match the Enterprise Profile')
486
+ }
487
+ session = {
488
+ ...session,
489
+ capabilities: Array.isArray(bootstrap.capabilities) ? bootstrap.capabilities.filter(value => typeof value === 'string').slice(0, 256) : [],
490
+ }
491
+ await this.saveSession(profile, session)
492
+ const runtime = bootstrap.runtime_credential ?? {}
493
+ const state = String(runtime.status ?? '').toLowerCase()
494
+ let operation = 'resolve'
495
+ if (state === 'missing') {
496
+ if (options.allowProvision !== true) {
497
+ await this.ctx.credentials.unset(profile.keyBinding.credentialRef)
498
+ return account(profile, session, false, 'missing')
499
+ }
500
+ if (runtime.provisioning?.allowed === false) throw publicError('oidc_binding_denied', 'the organization does not allow credential provisioning')
501
+ operation = 'provision'
502
+ } else if (state === 'expired' || state === 'expiring') {
503
+ operation = runtime.api_key_id ? 'renew' : 'resolve'
504
+ } else if (state !== 'active') {
505
+ await this.ctx.credentials.unset(profile.keyBinding.credentialRef)
506
+ return account(profile, session, false, state || 'unavailable')
507
+ }
508
+ const payload = { provider_id: profile.keyBinding.providerId }
509
+ if (runtime.api_key_id) payload.api_key_id = runtime.api_key_id
510
+ const bindingCall = await this.authorized(profile, session, `${profile.keyBinding.baseURL}/runtime-credential/${operation}`, {
511
+ method: 'POST',
512
+ headers: { 'content-type': 'application/json', ...(operation === 'provision' || operation === 'renew' ? { 'idempotency-key': randomUUID() } : {}) },
513
+ body: JSON.stringify(payload),
514
+ })
515
+ session = bindingCall.session
516
+ const credential = await responseJSON(bindingCall.response, 'oidc_binding_failed')
517
+ if (credential.provider_id !== profile.keyBinding.providerId) throw publicError('oidc_binding_invalid', 'runtime credential provider is invalid')
518
+ if (typeof credential.api_key !== 'string' || credential.api_key === '' || credential.api_key.length > 16 * 1024) throw publicError('oidc_binding_invalid', 'runtime credential response has an invalid API key')
519
+ await this.ctx.credentials.set(profile.keyBinding.credentialRef, credential.api_key)
520
+ return account(profile, session, true, credential.status ?? 'active')
521
+ }
522
+
523
+ management() { return Promise.resolve(profileManagement(this.profiles)) }
524
+ activate() { return unsupportedManagement() }
525
+ configure() { return unsupportedManagement() }
526
+ addCustom() { return unsupportedManagement() }
527
+ updateCustom() { return unsupportedManagement() }
528
+ removeProfile() { return unsupportedManagement() }
529
+ configureModels() { return unsupportedManagement() }
530
+ restart() { return unsupportedManagement() }
531
+
532
+ async logout(profileID) {
533
+ const profile = this.profile(profileID)
534
+ const session = await this.loadSession(profile)
535
+ if (session) {
536
+ try {
537
+ const discovery = await this.discover(profile)
538
+ if (discovery.revocationEndpoint) {
539
+ const token = session.refreshToken || session.accessToken
540
+ await this.fetch(discovery.revocationEndpoint, {
541
+ method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
542
+ body: new URLSearchParams({ token, client_id: profile.oidc.clientId }), signal: AbortSignal.timeout(10_000),
543
+ })
544
+ }
545
+ } catch (cause) { this.ctx.logger.warn(cause instanceof Error ? cause : new Error(String(cause))) }
546
+ }
547
+ await Promise.all([
548
+ this.ctx.credentials.unset(sessionRef(profile)),
549
+ this.ctx.credentials.unset(profile.keyBinding.credentialRef),
550
+ ])
551
+ return account(profile, undefined, false)
552
+ }
553
+ }
554
+
555
+ export class NativeOidcBackend {
556
+ constructor(ctx, profiles) {
557
+ this.ctx = ctx
558
+ this.profiles = profiles
559
+ }
560
+
561
+ profile(id) {
562
+ const profile = this.profiles.get(id)
563
+ if (!profile) throw publicError('oidc_profile_unknown', `unknown OIDC profile ${id}`)
564
+ return profile
565
+ }
566
+
567
+ service() {
568
+ const service = this.ctx.get('enterpriseAccounts')
569
+ if (!service) throw publicError('oidc_native_adapter_unavailable', 'native OIDC adapter is unavailable')
570
+ return service
571
+ }
572
+
573
+ normalize(profile, status) {
574
+ if (status.runtimeCredentialRef !== undefined && status.runtimeCredentialRef !== profile.keyBinding.credentialRef) {
575
+ throw publicError(
576
+ 'oidc_native_credential_ref_mismatch',
577
+ `native OIDC adapter credential reference does not match Enterprise Profile ${profile.id}`,
578
+ )
579
+ }
580
+ return {
581
+ profileID: profile.id,
582
+ displayName: status.displayName ?? profile.displayName,
583
+ organization: status.organization ?? profile.organization,
584
+ state: status.state ?? (status.credentialReady ? 'connected' : 'signed_out'),
585
+ ...(status.userName ? { userName: status.userName } : {}),
586
+ ...(status.affiliation ? { affiliation: status.affiliation } : {}),
587
+ ...(status.accessExpiresAt ? { accessExpiresAt: status.accessExpiresAt } : {}),
588
+ credentialRef: profile.keyBinding.credentialRef,
589
+ credentialReady: status.credentialReady === true,
590
+ ...(status.credentialState ? { credentialState: status.credentialState } : {}),
591
+ capabilities: Array.isArray(status.capabilities) ? status.capabilities : [],
592
+ }
593
+ }
594
+
595
+ normalizeManagement(configuration) {
596
+ const active = String(configuration?.activeInstitutionID ?? '')
597
+ const institutions = Array.isArray(configuration?.institutions) ? configuration.institutions : []
598
+ return {
599
+ schemaVersion: 'dsh-oidc/management/v1alpha1', mode: 'native', activeProfileID: active,
600
+ restartRequired: configuration?.restartRequired === true,
601
+ capabilities: {
602
+ manageProfiles: ['activate', 'configure', 'addCustom', 'updateCustom', 'removeInstitution'].every(method => typeof this.service()[method] === 'function'),
603
+ manageModels: typeof this.service().configureCustomModels === 'function',
604
+ restart: typeof this.service().restart === 'function',
605
+ },
606
+ profiles: institutions.map(institution => ({
607
+ id: String(institution.id), displayName: String(institution.displayName), organization: String(institution.organization),
608
+ baseURL: String(institution.baseURL), builtIn: institution.builtIn === true, configured: institution.configured === true,
609
+ enabled: String(institution.id) === active, providerID: String(institution.providerID),
610
+ runtime: runtimeProjection(institution.runtime, { displayName: institution.displayName }),
611
+ })),
612
+ }
613
+ }
614
+
615
+ async management() { return this.normalizeManagement(await this.service().configuration()) }
616
+ async activate(profileID) { return this.normalizeManagement(await this.service().activate(profileID)) }
617
+ async configure(profileID) { return this.normalizeManagement(await this.service().configure(profileID)) }
618
+ async addCustom(baseURL) { return this.normalizeManagement(await this.service().addCustom(baseURL)) }
619
+ async updateCustom(profileID, baseURL) { return this.normalizeManagement(await this.service().updateCustom(profileID, baseURL)) }
620
+ async removeProfile(profileID) { return this.normalizeManagement(await this.service().removeInstitution(profileID)) }
621
+ async configureModels(profileID, modelMode, models) {
622
+ return this.normalizeManagement(await this.service().configureCustomModels(profileID, modelMode, models))
623
+ }
624
+ restart() { return this.service().restart() }
625
+
626
+ async status(profileID) {
627
+ const profile = this.profile(profileID)
628
+ return this.normalize(profile, await this.service().status(profile.nativeInstitutionID))
629
+ }
630
+
631
+ async begin(profileID) {
632
+ const profile = this.profile(profileID)
633
+ const status = await this.service().login(profile.nativeInstitutionID, { allowProvision: false })
634
+ return { mode: 'completed', status: this.normalize(profile, status) }
635
+ }
636
+
637
+ async reconcile(profileID, options = {}) {
638
+ const profile = this.profile(profileID)
639
+ const status = await this.service().reconcile(profile.nativeInstitutionID, { allowProvision: options.allowProvision === true })
640
+ return this.normalize(profile, status)
641
+ }
642
+
643
+ async logout(profileID) {
644
+ const profile = this.profile(profileID)
645
+ return this.normalize(profile, await this.service().logout(profile.nativeInstitutionID))
646
+ }
647
+ }
648
+
649
+ export { account, publicError, sessionRef }