@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.
- package/CHANGELOG.en.md +6 -0
- package/CHANGELOG.md +6 -0
- package/README.md +20 -132
- package/README_EN.md +20 -177
- package/docs/EDUWORK-MIGRATION.en.md +3 -25
- package/docs/EDUWORK-MIGRATION.md +3 -25
- package/docs/account-extensions.en.md +6 -6
- package/docs/account-extensions.md +3 -3
- package/docs/architecture.en.md +12 -117
- package/docs/architecture.md +12 -118
- package/docs/compatibility.en.md +6 -40
- package/docs/compatibility.md +6 -40
- package/docs/decisions/0001-one-plugin-composition-boundary.en.md +2 -2
- package/docs/decisions/0001-one-plugin-composition-boundary.md +2 -2
- package/docs/desktop-host.en.md +4 -5
- package/docs/desktop-host.md +8 -9
- package/docs/development.en.md +2 -2
- package/docs/development.md +2 -2
- package/docs/dsh-integration.en.md +11 -46
- package/docs/dsh-integration.md +12 -47
- package/docs/ecnu-reference.en.md +4 -61
- package/docs/ecnu-reference.md +4 -61
- package/docs/enterprise-profile.en.md +11 -32
- package/docs/enterprise-profile.md +12 -33
- package/docs/gateway-auth/README.md +168 -0
- package/docs/gateway-auth/README_EN.md +70 -0
- package/docs/gateway-auth/experimental-oidc-llm.en.md +53 -0
- package/docs/gateway-auth/experimental-oidc-llm.md +53 -0
- package/docs/gateway-auth/litellm-setup.en.md +114 -0
- package/docs/gateway-auth/litellm-setup.md +114 -0
- package/docs/gateway-auth/oidc-llm-draft.en.md +141 -0
- package/docs/gateway-auth/oidc-llm-draft.md +141 -0
- package/docs/getting-started.en.md +17 -122
- package/docs/getting-started.md +16 -225
- package/docs/key-binding-protocol.en.md +10 -220
- package/docs/key-binding-protocol.md +10 -220
- package/docs/oidc-interoperability.en.md +1 -1
- package/docs/oidc-interoperability.md +1 -1
- package/docs/open-integration.en.md +5 -67
- package/docs/open-integration.md +5 -67
- package/docs/public-resource-protocol.en.md +8 -44
- package/docs/public-resource-protocol.md +8 -43
- package/docs/security-model.en.md +10 -132
- package/docs/security-model.md +10 -132
- package/docs/server-integration-contract.en.md +12 -813
- package/docs/server-integration-contract.md +12 -825
- package/examples/ecnu.enterprise-profile.example.json +16 -20
- package/examples/enterprise-profile.example.json +16 -20
- package/examples/identity-only.example.json +7 -2
- package/examples/litellm.enterprise-profile.example.json +10 -0
- package/examples/oidc-llm.enterprise-profile.example.json +13 -0
- package/examples/resources-discovery.example.json +7 -9
- package/lib/callback-page.js +5 -5
- package/lib/client.js +31 -48
- package/lib/desktop-oidc.js +7 -11
- package/lib/gateway-backend.js +443 -0
- package/lib/gateway-protocol.js +6 -0
- package/lib/index.js +14 -15
- package/lib/litellm-protocol.js +117 -0
- package/lib/model-resource-transport.js +19 -0
- package/lib/oidc-llm-protocol.js +87 -0
- package/lib/oidc.js +40 -338
- package/lib/profile.js +42 -74
- package/lib/provider/authorization-scope.js +46 -0
- package/lib/provider/core.js +5 -16
- package/lib/provider/index.js +11 -2
- package/lib/transport.js +5 -0
- package/lib/typert-schemas.js +1 -1
- package/package.json +2 -2
- package/protocol/openapi.yaml +33 -240
- package/protocol/resources.openapi.yaml +8 -9
- package/schema/enterprise-profile.v1alpha1.schema.json +508 -107
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Shared bounded Host-only reads for optional resource extensions.
|
|
2
|
+
const error = (code, message) => Object.assign(new Error(message), { code })
|
|
3
|
+
|
|
4
|
+
export function validateResourceRead(relativePath, options) {
|
|
5
|
+
if (typeof relativePath !== 'string' || !/^\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(relativePath)
|
|
6
|
+
|| Object.keys(options).some(key => key !== 'signal')) throw error('oidc_resource_path_denied', 'A fixed model-resource path and read-only options are required')
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function bufferResourceResponse(response, assertCurrent) {
|
|
10
|
+
if (Number(response.headers.get('content-length')) > 1024 * 1024) { await response.body?.cancel(); throw error('oidc_resource_invalid', 'Model resource response is too large') }
|
|
11
|
+
const chunks = [], reader = response.body?.getReader()
|
|
12
|
+
let size = 0
|
|
13
|
+
if (reader) {
|
|
14
|
+
try { for (;;) { const { done, value } = await reader.read(); if (done) break; size += value.length; if (size > 1024 * 1024) throw new Error('size'); chunks.push(value) } }
|
|
15
|
+
catch { await reader.cancel().catch(() => {}); throw error('oidc_resource_invalid', 'Model resource response could not be read') }
|
|
16
|
+
}
|
|
17
|
+
await assertCurrent()
|
|
18
|
+
return new Response([204, 205, 304].includes(response.status) ? null : Buffer.concat(chunks), { status: response.status, headers: response.headers })
|
|
19
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { serviceProtocolAllowed } from './transport.js'
|
|
2
|
+
// Experimental draft 0.1. These wire rules are not a published standard.
|
|
3
|
+
import { protocolError } from './litellm-protocol.js'
|
|
4
|
+
|
|
5
|
+
const fail = message => { throw protocolError('gateway_discovery_invalid', message) }
|
|
6
|
+
const tokenText = value => typeof value === 'string' && value.length > 0 && value.length <= 65536
|
|
7
|
+
const includes = (raw, key, values) => {
|
|
8
|
+
if (!Array.isArray(raw[key]) || values.some(value => !raw[key].includes(value))) fail(`oidc-llm discovery lacks required ${key}`)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function endpoint(value, profile, origin) {
|
|
12
|
+
let url
|
|
13
|
+
try { url = new URL(value) } catch { fail('oidc-llm metadata contains an invalid URL') }
|
|
14
|
+
if (typeof value !== 'string' || value.length > 2048 || !serviceProtocolAllowed(url, profile.allowInsecureDevelopment)
|
|
15
|
+
|| url.username || url.password || url.search || url.hash || origin && url.origin !== origin) fail('oidc-llm URL is outside the configured trust boundary')
|
|
16
|
+
return url
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function detectOidcLlm(raw, profile) {
|
|
20
|
+
if (!profile.auth.experimentalOidcLlm) throw protocolError('gateway_protocol_unsupported', 'oidc-llm requires explicit experimental opt-in')
|
|
21
|
+
if (raw.contract_version !== undefined) fail('Ambiguous gateway discovery contracts')
|
|
22
|
+
const extension = raw.oidc_llm
|
|
23
|
+
if (!extension || Array.isArray(extension) || extension.version !== '0.1') throw protocolError('gateway_protocol_unsupported', 'Unsupported oidc-llm draft version')
|
|
24
|
+
const issuer = endpoint(raw.issuer, profile)
|
|
25
|
+
if (profile.auth.expectedIssuer ? raw.issuer !== profile.auth.expectedIssuer : issuer.origin !== new URL(profile.auth.discoveryUrl).origin) fail('oidc-llm issuer does not match the configured trust anchor')
|
|
26
|
+
// An OIDC well-known document has an issuer-location relationship, even with a pin.
|
|
27
|
+
if (new URL(profile.auth.discoveryUrl).pathname.endsWith('/.well-known/openid-configuration')
|
|
28
|
+
&& profile.auth.discoveryUrl !== raw.issuer.replace(/\/+$/, '') + '/.well-known/openid-configuration') fail('OIDC discovery location does not match issuer')
|
|
29
|
+
const resource = endpoint(extension.resource, profile)
|
|
30
|
+
const identityMode = profile.auth.identityMode
|
|
31
|
+
includes(extension, 'identity_modes_supported', ['oauth', identityMode])
|
|
32
|
+
includes(extension, 'client_registration_methods_supported', ['static'])
|
|
33
|
+
const scopes = identityMode === 'oidc'
|
|
34
|
+
? ['openid', 'profile', 'offline_access', 'llm:models:read', 'llm:invoke']
|
|
35
|
+
: ['llm:profile', 'llm:models:read', 'llm:invoke']
|
|
36
|
+
for (const [key, values] of Object.entries({
|
|
37
|
+
response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
38
|
+
code_challenge_methods_supported: ['S256'], token_endpoint_auth_methods_supported: ['none'],
|
|
39
|
+
revocation_endpoint_auth_methods_supported: ['none'], scopes_supported: scopes,
|
|
40
|
+
})) includes(raw, key, values)
|
|
41
|
+
const result = {
|
|
42
|
+
protocol: 'oidc-llm-draft-0.1', issuer: raw.issuer, resource: extension.resource,
|
|
43
|
+
identityMode, clientId: profile.auth.clientId, scopes,
|
|
44
|
+
requireResponseIssuer: raw.authorization_response_iss_parameter_supported === true,
|
|
45
|
+
}
|
|
46
|
+
for (const [key, wire] of Object.entries({ authorizationEndpoint: 'authorization_endpoint', tokenEndpoint: 'token_endpoint', revocationEndpoint: 'revocation_endpoint' })) {
|
|
47
|
+
endpoint(raw[wire], profile)
|
|
48
|
+
result[key] = raw[wire]
|
|
49
|
+
}
|
|
50
|
+
endpoint(raw.userinfo_endpoint, profile, resource.origin)
|
|
51
|
+
endpoint(extension.api_base, profile, resource.origin)
|
|
52
|
+
result.userInfoEndpoint = raw.userinfo_endpoint
|
|
53
|
+
result.baseURL = extension.api_base.replace(/\/+$/, '')
|
|
54
|
+
if (identityMode === 'oidc') {
|
|
55
|
+
includes(raw, 'id_token_signing_alg_values_supported', ['RS256'])
|
|
56
|
+
if (!Array.isArray(raw.subject_types_supported) || !raw.subject_types_supported.some(v => ['public', 'pairwise'].includes(v))) fail('OIDC subject type is missing')
|
|
57
|
+
endpoint(raw.jwks_uri, profile)
|
|
58
|
+
result.jwksURI = raw.jwks_uri
|
|
59
|
+
}
|
|
60
|
+
return Object.freeze(result)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function oidcLlmToken(raw, descriptor, now, previous) {
|
|
64
|
+
if (!tokenText(raw.access_token) || !tokenText(raw.refresh_token) || typeof raw.token_type !== 'string'
|
|
65
|
+
|| raw.token_type.toLowerCase() !== 'bearer' || !Number.isSafeInteger(raw.expires_in)
|
|
66
|
+
|| raw.expires_in <= 0 || raw.expires_in > 365 * 86400 || typeof raw.scope !== 'string') {
|
|
67
|
+
throw protocolError('gateway_token_invalid', 'oidc-llm token response is incomplete or invalid')
|
|
68
|
+
}
|
|
69
|
+
const scopes = raw.scope.split(' ')
|
|
70
|
+
const allowed = previous?.scopes ?? descriptor.scopes
|
|
71
|
+
if (scopes.some(scope => !scope || !allowed.includes(scope)) || new Set(scopes).size !== scopes.length
|
|
72
|
+
|| descriptor.scopes.some(scope => !scopes.includes(scope))) {
|
|
73
|
+
throw protocolError('gateway_scope_changed', 'oidc-llm granted scopes do not match the requested authorization')
|
|
74
|
+
}
|
|
75
|
+
// No unapproved 15-minute ceiling: honor the actual advertised TTL.
|
|
76
|
+
return { accessToken: raw.access_token, refreshToken: raw.refresh_token, expiresAt: Math.floor(now() / 1000) + raw.expires_in, scopes, teamID: null }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function oidcLlmIdentity(raw) {
|
|
80
|
+
if (typeof raw.sub !== 'string' || !/^[\x21-\x7e]{1,255}$/.test(raw.sub)) throw protocolError('oidc_userinfo_invalid', 'UserInfo has no valid subject')
|
|
81
|
+
for (const key of ['name', 'preferred_username', 'picture', 'email']) {
|
|
82
|
+
if (raw[key] !== undefined && typeof raw[key] !== 'string') throw protocolError('oidc_userinfo_invalid', 'UserInfo claim type is invalid')
|
|
83
|
+
}
|
|
84
|
+
if (raw.email_verified !== undefined && typeof raw.email_verified !== 'boolean') throw protocolError('oidc_userinfo_invalid', 'UserInfo claim type is invalid')
|
|
85
|
+
// Existing account UI consumes only the stable subject and display name.
|
|
86
|
+
return { sub: raw.sub, name: raw.name?.trim().slice(0, 256) || raw.preferred_username?.trim().slice(0, 256) || '已登录' }
|
|
87
|
+
}
|
package/lib/oidc.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { serviceProtocolAllowed } from './transport.js'
|
|
2
|
+
import { createHash, createPublicKey, randomBytes, timingSafeEqual, verify as verifySignature } from 'node:crypto'
|
|
3
|
+
import { emptyResources } from './resources.js'
|
|
4
4
|
|
|
5
5
|
const FLOW_TTL_MS = 10 * 60 * 1000
|
|
6
6
|
const MAX_PENDING_FLOWS = 32
|
|
@@ -44,10 +44,6 @@ async function responseJSON(response, stage) {
|
|
|
44
44
|
return value
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
function isLoopback(hostname) {
|
|
48
|
-
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'
|
|
49
|
-
}
|
|
50
|
-
|
|
51
47
|
/** OIDC metadata endpoints may use origins distinct from the issuer over HTTPS. */
|
|
52
48
|
function discoveredEndpoint(value, profile, label, optional = false) {
|
|
53
49
|
if (optional && (value === undefined || value === '')) return ''
|
|
@@ -55,17 +51,8 @@ function discoveredEndpoint(value, profile, label, optional = false) {
|
|
|
55
51
|
let endpoint
|
|
56
52
|
try { endpoint = new URL(value) }
|
|
57
53
|
catch (cause) { throw publicError('oidc_discovery_invalid', `OIDC ${label} is not an absolute URL`, cause) }
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const localDevelopment = issuerURL.protocol === 'http:' && isLoopback(issuerURL.hostname)
|
|
61
|
-
&& endpoint.protocol === 'http:' && isLoopback(endpoint.hostname)
|
|
62
|
-
const explicitlyAllowedDevelopment = profile.allowInsecureDevelopment === true
|
|
63
|
-
&& typeof profile.insecureDevelopmentOrigin === 'string'
|
|
64
|
-
&& issuerURL.origin === profile.insecureDevelopmentOrigin
|
|
65
|
-
&& endpoint.protocol === 'http:'
|
|
66
|
-
&& endpoint.origin === profile.insecureDevelopmentOrigin
|
|
67
|
-
if ((!secure && !localDevelopment && !explicitlyAllowedDevelopment) || endpoint.username || endpoint.password || endpoint.hash) {
|
|
68
|
-
throw publicError('oidc_discovery_invalid', `OIDC ${label} must be HTTPS, loopback HTTP, or the profile's exact development HTTP origin`)
|
|
54
|
+
if (!serviceProtocolAllowed(endpoint, profile.allowInsecureDevelopment) || endpoint.username || endpoint.password || endpoint.hash) {
|
|
55
|
+
throw publicError('oidc_discovery_invalid', `OIDC ${label} must be HTTPS; set allowInsecureDevelopment=true to allow HTTP`)
|
|
69
56
|
}
|
|
70
57
|
return endpoint.toString()
|
|
71
58
|
}
|
|
@@ -74,22 +61,6 @@ function sessionRef(profile) {
|
|
|
74
61
|
return `DSH_OIDC_${profile.id.toUpperCase().replace(/-/g, '_')}_SESSION`
|
|
75
62
|
}
|
|
76
63
|
|
|
77
|
-
function resourceBinding(profile) {
|
|
78
|
-
return profile.keyBinding ? {
|
|
79
|
-
managementBaseURL: profile.keyBinding.baseURL, runtimeBaseURL: profile.provider.baseURL,
|
|
80
|
-
providerID: profile.provider.id, credentialRef: profile.keyBinding.credentialRef,
|
|
81
|
-
} : null
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function credentialHash(value) {
|
|
85
|
-
return typeof value === 'string' && value ? createHash('sha256').update(value).digest('hex') : ''
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function sameResourceBinding(left, right, ignoreRef = false) {
|
|
89
|
-
return Boolean(left && right && ['managementBaseURL', 'runtimeBaseURL', 'providerID', ...(ignoreRef ? [] : ['credentialRef'])]
|
|
90
|
-
.every(key => left[key] === right[key]))
|
|
91
|
-
}
|
|
92
|
-
|
|
93
64
|
function nowSeconds(now) {
|
|
94
65
|
return Math.floor(now() / 1000)
|
|
95
66
|
}
|
|
@@ -103,11 +74,11 @@ function account(profile, session, credentialReady, credentialState = '') {
|
|
|
103
74
|
profileID: profile.id,
|
|
104
75
|
displayName: profile.displayName,
|
|
105
76
|
organization: profile.organization,
|
|
106
|
-
state: session ?
|
|
77
|
+
state: session ? 'connected' : 'signed_out',
|
|
107
78
|
...(session?.identity?.name ? { userName: session.identity.name } : {}),
|
|
108
79
|
...(session?.identity?.affiliation ? { affiliation: session.identity.affiliation } : {}),
|
|
109
80
|
...(session?.expiresAt ? { accessExpiresAt: new Date(session.expiresAt * 1000).toISOString() } : {}),
|
|
110
|
-
credentialRef:
|
|
81
|
+
credentialRef: '',
|
|
111
82
|
credentialReady,
|
|
112
83
|
...(credentialState ? { credentialState } : {}),
|
|
113
84
|
capabilities: Array.isArray(session?.capabilities) ? session.capabilities : [],
|
|
@@ -145,7 +116,7 @@ function runtimeProjection(runtime = {}, fallback = {}) {
|
|
|
145
116
|
function profileManagement(profiles) {
|
|
146
117
|
const rows = [...profiles.values()].map(profile => ({
|
|
147
118
|
id: profile.id, displayName: profile.displayName, organization: profile.organization,
|
|
148
|
-
baseURL: profile.provider?.baseURL
|
|
119
|
+
baseURL: profile.provider?.baseURL || profile.auth?.discoveryUrl || profile.oidc.issuer, builtIn: true, configured: true, enabled: true,
|
|
149
120
|
providerID: profile.provider?.id ?? '',
|
|
150
121
|
runtime: runtimeProjection({ ...profile.provider, modelSource: profile.provider?.modelSource ?? 'none' }, profile.provider),
|
|
151
122
|
}))
|
|
@@ -269,6 +240,7 @@ export class WebOidcBackend {
|
|
|
269
240
|
}
|
|
270
241
|
const result = Object.freeze({
|
|
271
242
|
issuer: raw.issuer,
|
|
243
|
+
requireResponseIssuer: raw.authorization_response_iss_parameter_supported === true,
|
|
272
244
|
authorizationEndpoint: discoveredEndpoint(raw.authorization_endpoint, profile, 'authorization_endpoint'),
|
|
273
245
|
tokenEndpoint: discoveredEndpoint(raw.token_endpoint, profile, 'token_endpoint'),
|
|
274
246
|
userInfoEndpoint: discoveredEndpoint(raw.userinfo_endpoint, profile, 'userinfo_endpoint'),
|
|
@@ -284,7 +256,7 @@ export class WebOidcBackend {
|
|
|
284
256
|
const discovery = await this.discover(profile)
|
|
285
257
|
this.pruneFlows()
|
|
286
258
|
if (this.flows.size >= MAX_PENDING_FLOWS) throw publicError('oidc_flow_limit', 'too many pending OIDC login attempts')
|
|
287
|
-
const { authorizationURL } = this.createAuthorization(profile, discovery, this.redirectURI)
|
|
259
|
+
const { authorizationURL } = await this.createAuthorization(profile, discovery, this.redirectURI)
|
|
288
260
|
return { mode: 'redirect', authorizationURL }
|
|
289
261
|
}
|
|
290
262
|
|
|
@@ -323,9 +295,9 @@ export class WebOidcBackend {
|
|
|
323
295
|
const profile = this.profile(flow.profileID)
|
|
324
296
|
const session = await this.exchangeAuthorization(requested, flow)
|
|
325
297
|
await this.saveSession(profile, session, flow.epoch)
|
|
326
|
-
const status = await this.reconcile(profile.id
|
|
298
|
+
const status = await this.reconcile(profile.id)
|
|
327
299
|
this.accountChanged(status)
|
|
328
|
-
outcome =
|
|
300
|
+
outcome = 'connected'
|
|
329
301
|
} catch (cause) {
|
|
330
302
|
this.ctx.logger.warn('OIDC callback failed', { code: cause?.code ?? 'oidc_callback_failed' })
|
|
331
303
|
outcome = cause?.code ?? 'oidc_callback_failed'
|
|
@@ -339,11 +311,14 @@ export class WebOidcBackend {
|
|
|
339
311
|
|
|
340
312
|
async exchangeAuthorization(requested, flow) {
|
|
341
313
|
const profile = this.profile(flow.profileID)
|
|
314
|
+
const discovery = await this.discover(profile)
|
|
315
|
+
const issuers = requested.searchParams.getAll('iss')
|
|
316
|
+
if ((!issuers.length && discovery.requireResponseIssuer) || issuers.length > 1
|
|
317
|
+
|| (issuers.length === 1 && !equalText(issuers[0], discovery.issuer))) {
|
|
318
|
+
throw publicError('oidc_callback_invalid', 'OIDC authorization response issuer is missing or invalid')
|
|
319
|
+
}
|
|
342
320
|
if (singleQueryParameter(requested, 'error')) throw publicError('oidc_authorization_rejected', 'OIDC authorization was rejected')
|
|
343
|
-
|
|
344
|
-
const issuer = singleQueryParameter(requested, 'iss')
|
|
345
|
-
if (issuer && !equalText(issuer, profile.oidc.issuer)) throw publicError('oidc_callback_invalid', 'OIDC authorization response issuer is invalid')
|
|
346
|
-
return this.exchangeCode(profile, await this.discover(profile), code, flow.verifier, flow.nonce, flow.redirectURI)
|
|
321
|
+
return this.exchangeCode(profile, discovery, singleQueryParameter(requested, 'code', true), flow.verifier, flow.nonce, flow.redirectURI)
|
|
347
322
|
}
|
|
348
323
|
|
|
349
324
|
async exchangeCode(profile, discovery, code, verifier, nonce, redirectURI = this.redirectURI) {
|
|
@@ -381,13 +356,14 @@ export class WebOidcBackend {
|
|
|
381
356
|
}
|
|
382
357
|
}
|
|
383
358
|
|
|
384
|
-
async verifyIDToken(profile, discovery, raw, expectedNonce, accessToken) {
|
|
359
|
+
async verifyIDToken(profile, discovery, raw, expectedNonce, accessToken, { refresh = false } = {}) {
|
|
360
|
+
if (typeof raw !== 'string') throw publicError('oidc_id_token_invalid', 'OIDC ID Token is missing')
|
|
385
361
|
const parts = raw.split('.')
|
|
386
362
|
if (parts.length !== 3) throw publicError('oidc_id_token_invalid', 'OIDC ID Token is malformed')
|
|
387
363
|
const header = decodePart(parts[0], 'ID Token header')
|
|
388
364
|
const claims = decodePart(parts[1], 'ID Token claims')
|
|
389
365
|
if (header.alg !== 'RS256' || typeof header.kid !== 'string' || header.kid === '') throw publicError('oidc_id_token_invalid', 'OIDC ID Token must use RS256 with kid')
|
|
390
|
-
const keysResponse = await this.fetch(discovery.jwksURI, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(20_000) })
|
|
366
|
+
const keysResponse = await this.fetch(discovery.jwksURI, { headers: { accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(20_000) })
|
|
391
367
|
const jwks = await responseJSON(keysResponse, 'oidc_jwks_failed')
|
|
392
368
|
const candidates = Array.isArray(jwks.keys) ? jwks.keys.filter(key => (
|
|
393
369
|
key.kid === header.kid && key.kty === 'RSA' && (key.use === undefined || key.use === 'sig')
|
|
@@ -402,8 +378,12 @@ export class WebOidcBackend {
|
|
|
402
378
|
const now = nowSeconds(this.now)
|
|
403
379
|
const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud]
|
|
404
380
|
const audienceValid = audience.includes(profile.oidc.clientId)
|
|
381
|
+
&& audience.every(value => typeof value === 'string' && value !== '')
|
|
382
|
+
&& new Set(audience).size === audience.length
|
|
405
383
|
&& (audience.length === 1 || claims.azp === profile.oidc.clientId)
|
|
406
|
-
|
|
384
|
+
&& (claims.azp === undefined || claims.azp === profile.oidc.clientId)
|
|
385
|
+
const nonceValid = refresh && claims.nonce === undefined || equalText(claims.nonce, expectedNonce)
|
|
386
|
+
if (claims.iss !== profile.oidc.issuer || !audienceValid || typeof claims.sub !== 'string' || claims.sub === '' || !nonceValid) {
|
|
407
387
|
throw publicError('oidc_id_token_invalid', 'OIDC ID Token identity claims are invalid')
|
|
408
388
|
}
|
|
409
389
|
if (!Number.isFinite(claims.exp) || !Number.isFinite(claims.iat)
|
|
@@ -435,93 +415,21 @@ export class WebOidcBackend {
|
|
|
435
415
|
try { session = JSON.parse(record.value) } catch { await this.ctx.credentials.unset(sessionRef(profile)); return undefined }
|
|
436
416
|
if (session?.issuer !== profile.oidc.issuer || session?.clientId !== profile.oidc.clientId
|
|
437
417
|
|| typeof session?.accessToken !== 'string' || !Number.isFinite(session?.expiresAt)
|
|
438
|
-
|| typeof session?.identity?.sub !== 'string'
|
|
439
|
-
|
|
418
|
+
|| typeof session?.identity?.sub !== 'string' || session.resourceBinding !== undefined
|
|
419
|
+
|| session.runtimeCredentialHash !== undefined) {
|
|
440
420
|
await this.ctx.credentials.unset(sessionRef(profile))
|
|
441
421
|
return undefined
|
|
442
422
|
}
|
|
443
|
-
const binding = resourceBinding(profile)
|
|
444
|
-
if (binding) {
|
|
445
|
-
const current = await this.ctx.credentials.resolve(binding.credentialRef)
|
|
446
|
-
if (!sameResourceBinding(session.resourceBinding, binding)) {
|
|
447
|
-
const oldRef = session.resourceBinding?.credentialRef
|
|
448
|
-
const renameOnly = binding.credentialRef === DEFAULT_CREDENTIAL_REF
|
|
449
|
-
&& legacyCredentialRefs(profile).includes(oldRef)
|
|
450
|
-
&& sameResourceBinding(session.resourceBinding, binding, true)
|
|
451
|
-
const old = renameOnly ? await this.ctx.credentials.resolve(oldRef) : undefined
|
|
452
|
-
if (renameOnly && !current?.value && old?.value
|
|
453
|
-
&& ![...this.profiles.values()].some(value => value.id !== profile.id && value.keyBinding?.credentialRef === oldRef)
|
|
454
|
-
&& (session.runtimeCredentialHash === undefined || session.runtimeCredentialHash === credentialHash(old.value))) {
|
|
455
|
-
await this.ctx.credentials.set(binding.credentialRef, old.value)
|
|
456
|
-
session.runtimeCredentialHash = credentialHash(old.value)
|
|
457
|
-
await this.ctx.credentials.unset(oldRef)
|
|
458
|
-
} else {
|
|
459
|
-
// A rename never adopts or overwrites an already populated target.
|
|
460
|
-
// Changed endpoints retain identity but require a fresh binding.
|
|
461
|
-
if (!renameOnly) await this.clearOwnedCredential(profile, session)
|
|
462
|
-
session.runtimeCredentialHash = ''
|
|
463
|
-
}
|
|
464
|
-
session.resourceBinding = binding
|
|
465
|
-
} else if (session.runtimeCredentialHash === undefined) {
|
|
466
|
-
// Only unambiguous pre-fingerprint sessions may adopt their old key.
|
|
467
|
-
// New logins always save an explicit empty hash until binding succeeds.
|
|
468
|
-
session.runtimeCredentialHash = this.uniqueCredentialRef(profile) ? credentialHash(current?.value) : ''
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
const encoded = JSON.stringify(session)
|
|
472
|
-
if (encoded !== record.value) await this.ctx.credentials.set(sessionRef(profile), encoded)
|
|
473
423
|
return session
|
|
474
424
|
})
|
|
475
425
|
}
|
|
476
426
|
|
|
477
|
-
uniqueCredentialRef(profile) {
|
|
478
|
-
return [...this.profiles.values()].filter(value => value.keyBinding?.credentialRef === profile.keyBinding?.credentialRef).length === 1
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
async boundCredential(profile, session) {
|
|
482
|
-
if (!session?.runtimeCredentialHash || !sameResourceBinding(session.resourceBinding, resourceBinding(profile))) return undefined
|
|
483
|
-
const credential = await this.ctx.credentials.resolve(profile.keyBinding.credentialRef)
|
|
484
|
-
return credentialHash(credential?.value) === session.runtimeCredentialHash ? credential : undefined
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
async resolveBoundCredential(profileID, expected) {
|
|
488
|
-
const profile = this.profiles.get(profileID)
|
|
489
|
-
if (!profile?.keyBinding || !expected || expected.credentialRef !== profile.keyBinding.credentialRef
|
|
490
|
-
|| expected.runtimeBaseURL !== profile.provider.baseURL) return undefined
|
|
491
|
-
const epoch = this.accountEpoch(profile)
|
|
492
|
-
const credential = await this.boundCredential(profile, await this.loadSession(profile))
|
|
493
|
-
return epoch === this.accountEpoch(profile) ? credential : undefined
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
async clearOwnedCredential(profile, session) {
|
|
497
|
-
if (!profile.keyBinding || session?.resourceBinding?.credentialRef !== profile.keyBinding.credentialRef) return
|
|
498
|
-
const credential = await this.ctx.credentials.resolve(profile.keyBinding.credentialRef)
|
|
499
|
-
const proven = session.runtimeCredentialHash ? credentialHash(credential?.value) === session.runtimeCredentialHash
|
|
500
|
-
: session.runtimeCredentialHash === undefined && this.uniqueCredentialRef(profile)
|
|
501
|
-
if (proven) await this.ctx.credentials.unset(profile.keyBinding.credentialRef)
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
setRuntimeCredential(profile, session, value, epoch = this.accountEpoch(profile)) {
|
|
505
|
-
return this.writeCredentials(profile, epoch, async () => {
|
|
506
|
-
const record = await this.ctx.credentials.resolve(sessionRef(profile))
|
|
507
|
-
const saved = record?.value ? JSON.parse(record.value) : undefined
|
|
508
|
-
if (!saved || saved.issuer !== session.issuer || saved.clientId !== session.clientId
|
|
509
|
-
|| saved.identity?.sub !== session.identity?.sub || !sameResourceBinding(saved.resourceBinding, resourceBinding(profile))) {
|
|
510
|
-
throw publicError('oidc_login_cancelled', '账户状态已改变,请重新登录。')
|
|
511
|
-
}
|
|
512
|
-
if (value) await this.ctx.credentials.set(profile.keyBinding.credentialRef, value)
|
|
513
|
-
else await this.clearOwnedCredential(profile, saved)
|
|
514
|
-
saved.runtimeCredentialHash = credentialHash(value)
|
|
515
|
-
await this.ctx.credentials.set(sessionRef(profile), JSON.stringify(saved))
|
|
516
|
-
})
|
|
517
|
-
}
|
|
518
|
-
|
|
519
427
|
accountEpoch(profile) { return this.accountEpochs.get(profile.id) ?? 0 }
|
|
520
428
|
|
|
521
429
|
writeCredentials(profile, epoch, operation) {
|
|
522
430
|
// Serialize vault writes so logout clears even a write already in progress.
|
|
523
431
|
// Network requests do not hold this queue; late replies cannot restore keys.
|
|
524
|
-
const queue =
|
|
432
|
+
const queue = sessionRef(profile)
|
|
525
433
|
const write = (this.credentialWrites.get(queue) ?? Promise.resolve()).catch(() => {}).then(() => {
|
|
526
434
|
if (epoch !== this.accountEpoch(profile)) throw publicError('oidc_login_cancelled', '账户状态已改变,请重新登录。')
|
|
527
435
|
return operation()
|
|
@@ -532,18 +440,11 @@ export class WebOidcBackend {
|
|
|
532
440
|
}
|
|
533
441
|
|
|
534
442
|
clearCredentials(profile, epoch) {
|
|
535
|
-
return this.writeCredentials(profile, epoch,
|
|
536
|
-
const record = await this.ctx.credentials.resolve(sessionRef(profile))
|
|
537
|
-
let saved
|
|
538
|
-
try { saved = JSON.parse(record?.value) } catch { /* Malformed records cannot prove key ownership. */ }
|
|
539
|
-
await this.clearOwnedCredential(profile, saved)
|
|
540
|
-
await this.ctx.credentials.unset(sessionRef(profile))
|
|
541
|
-
})
|
|
443
|
+
return this.writeCredentials(profile, epoch, () => this.ctx.credentials.unset(sessionRef(profile)))
|
|
542
444
|
}
|
|
543
445
|
|
|
544
446
|
saveSession(profile, session, epoch = this.accountEpoch(profile)) {
|
|
545
|
-
return this.writeCredentials(profile, epoch, () => this.ctx.credentials.set(sessionRef(profile), JSON.stringify(
|
|
546
|
-
runtimeCredentialHash: session.runtimeCredentialHash ?? '', resourceBinding: resourceBinding(profile) })))
|
|
447
|
+
return this.writeCredentials(profile, epoch, () => this.ctx.credentials.set(sessionRef(profile), JSON.stringify(session)))
|
|
547
448
|
}
|
|
548
449
|
|
|
549
450
|
refresh(profile, session) {
|
|
@@ -610,12 +511,8 @@ export class WebOidcBackend {
|
|
|
610
511
|
const profile = this.profile(profileID)
|
|
611
512
|
const epoch = this.accountEpoch(profile)
|
|
612
513
|
const session = await this.loadSession(profile)
|
|
613
|
-
const credential = profile.keyBinding ? await this.boundCredential(profile, session) : undefined
|
|
614
|
-
if (session && credential?.value && profile.provider?.modelSource === 'discovery' && !this.resourceStates.has(profileID)) {
|
|
615
|
-
await this.resources(profileID)
|
|
616
|
-
}
|
|
617
514
|
if (epoch !== this.accountEpoch(profile)) return account(profile, undefined, false)
|
|
618
|
-
return account(profile, session,
|
|
515
|
+
return account(profile, session, false, 'not_required')
|
|
619
516
|
}
|
|
620
517
|
|
|
621
518
|
async authorized(profile, session, endpoint, init = {}) {
|
|
@@ -639,7 +536,6 @@ export class WebOidcBackend {
|
|
|
639
536
|
const target = new URL(endpoint)
|
|
640
537
|
const allowed = new Set([
|
|
641
538
|
new URL(profile.oidc.issuer).origin,
|
|
642
|
-
...(profile.keyBinding ? [new URL(profile.keyBinding.baseURL).origin] : []),
|
|
643
539
|
...this.authorizedOrigins,
|
|
644
540
|
])
|
|
645
541
|
if (!allowed.has(target.origin) || target.username || target.password || target.hash) {
|
|
@@ -650,87 +546,15 @@ export class WebOidcBackend {
|
|
|
650
546
|
return (await this.authorized(profile, session, target.toString(), init)).response
|
|
651
547
|
}
|
|
652
548
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
if (!profile.provider || !profile.keyBinding) throw publicError('oidc_resource_not_configured', 'Organization model resources are not configured')
|
|
657
|
-
if (typeof relativePath !== 'string' || !/^\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(relativePath)
|
|
658
|
-
|| Object.keys(options).some(key => key !== 'signal')) throw publicError('oidc_resource_path_denied', 'A fixed model-resource path and read-only options are required')
|
|
659
|
-
const session = await this.activeSession(profile)
|
|
660
|
-
const credential = await this.boundCredential(profile, session)
|
|
661
|
-
if (!session || !credential?.value || epoch !== this.accountEpoch(profile)) throw publicError('oidc_login_required', 'Organization model sign-in is required')
|
|
662
|
-
const response = await this.fetch(`${profile.provider.baseURL}${relativePath}`, {
|
|
663
|
-
method: 'GET', headers: { authorization: `Bearer ${credential.value}`, accept: 'application/json' },
|
|
664
|
-
redirect: 'error', signal: AbortSignal.any([AbortSignal.timeout(20_000), ...(options.signal ? [options.signal] : [])]),
|
|
665
|
-
})
|
|
666
|
-
// Complete the bounded body while the request signal is active. An extension
|
|
667
|
-
// never receives a response belonging to a signed-out or replaced account.
|
|
668
|
-
if (Number(response.headers.get('content-length')) > 1024 * 1024) { await response.body?.cancel(); throw publicError('oidc_resource_invalid', 'Model resource response is too large') }
|
|
669
|
-
const chunks = [], reader = response.body?.getReader()
|
|
670
|
-
let size = 0
|
|
671
|
-
if (reader) {
|
|
672
|
-
try { for (;;) { const { done, value } = await reader.read(); if (done) break; size += value.length; if (size > 1024 * 1024) throw new Error('size'); chunks.push(value) } }
|
|
673
|
-
catch { await reader.cancel().catch(() => {}); throw publicError('oidc_resource_invalid', 'Model resource response could not be read') }
|
|
674
|
-
}
|
|
675
|
-
const currentCredential = await this.ctx.credentials.resolve(profile.keyBinding.credentialRef)
|
|
676
|
-
if (epoch !== this.accountEpoch(profile) || currentCredential?.value !== credential.value) throw publicError('oidc_login_cancelled', 'Organization account changed during resource request')
|
|
677
|
-
return new Response([204, 205, 304].includes(response.status) ? null : Buffer.concat(chunks), { status: response.status, headers: response.headers })
|
|
549
|
+
async modelResourceFetch(profileID) {
|
|
550
|
+
this.profile(profileID)
|
|
551
|
+
throw publicError('oidc_resource_not_configured', 'Identity-only sign-in has no model resources; configure gateway authorization')
|
|
678
552
|
}
|
|
679
553
|
|
|
680
|
-
async reconcile(profileID
|
|
554
|
+
async reconcile(profileID) {
|
|
681
555
|
const profile = this.profile(profileID)
|
|
682
|
-
const
|
|
683
|
-
|
|
684
|
-
if (!session) return account(profile, undefined, false)
|
|
685
|
-
if (!profile.keyBinding) return account(profile, session, false, 'not_required')
|
|
686
|
-
const bootstrapCall = await this.authorized(profile, session, `${profile.keyBinding.baseURL}/bootstrap`)
|
|
687
|
-
session = bootstrapCall.session
|
|
688
|
-
const bootstrap = await responseJSON(bootstrapCall.response, 'oidc_binding_bootstrap_failed')
|
|
689
|
-
if (bootstrap.protocol_version !== undefined && !['worker.user-center.v1', 'worker-user-center/v1', 'eduwork-resources/v1'].includes(bootstrap.protocol_version)) {
|
|
690
|
-
throw publicError('oidc_binding_invalid', '身份登录已完成,但模型资源服务的协议版本不受支持。请联系服务管理员检查接口版本。')
|
|
691
|
-
}
|
|
692
|
-
if (bootstrap.provider?.id !== profile.keyBinding.providerId) {
|
|
693
|
-
throw publicError('oidc_binding_invalid', '身份登录已完成,但模型资源服务与配置中的 Provider ID 不一致。请检查企业配置文件。')
|
|
694
|
-
}
|
|
695
|
-
session = {
|
|
696
|
-
...session,
|
|
697
|
-
capabilities: Array.isArray(bootstrap.capabilities) ? bootstrap.capabilities.filter(value => typeof value === 'string').slice(0, 256) : [],
|
|
698
|
-
}
|
|
699
|
-
await this.saveSession(profile, session, epoch)
|
|
700
|
-
const runtime = bootstrap.runtime_credential ?? {}
|
|
701
|
-
const state = String(runtime.status ?? '').toLowerCase()
|
|
702
|
-
let operation = 'resolve'
|
|
703
|
-
if (state === 'missing') {
|
|
704
|
-
if (options.allowProvision !== true) {
|
|
705
|
-
await this.setRuntimeCredential(profile, session, '', epoch)
|
|
706
|
-
return account(profile, session, false, 'missing')
|
|
707
|
-
}
|
|
708
|
-
if (runtime.provisioning?.allowed === false) throw publicError('oidc_binding_denied', 'the organization does not allow credential provisioning')
|
|
709
|
-
operation = 'provision'
|
|
710
|
-
} else if (state === 'expired' || state === 'expiring') {
|
|
711
|
-
operation = runtime.api_key_id ? 'renew' : 'resolve'
|
|
712
|
-
} else if (state !== 'active') {
|
|
713
|
-
await this.setRuntimeCredential(profile, session, '', epoch)
|
|
714
|
-
return account(profile, session, false, state || 'unavailable')
|
|
715
|
-
}
|
|
716
|
-
const payload = { provider_id: profile.keyBinding.providerId }
|
|
717
|
-
if (runtime.api_key_id) payload.api_key_id = runtime.api_key_id
|
|
718
|
-
const bindingCall = await this.authorized(profile, session, `${profile.keyBinding.baseURL}/runtime-credential/${operation}`, {
|
|
719
|
-
method: 'POST',
|
|
720
|
-
headers: { 'content-type': 'application/json', ...(operation === 'provision' || operation === 'renew' ? { 'idempotency-key': randomUUID() } : {}) },
|
|
721
|
-
body: JSON.stringify(payload),
|
|
722
|
-
})
|
|
723
|
-
session = bindingCall.session
|
|
724
|
-
const credential = await responseJSON(bindingCall.response, 'oidc_binding_failed')
|
|
725
|
-
if (credential.provider_id !== profile.keyBinding.providerId) throw publicError('oidc_binding_invalid', 'runtime credential provider is invalid')
|
|
726
|
-
if (typeof credential.api_key !== 'string' || credential.api_key === '' || credential.api_key.length > 16 * 1024) throw publicError('oidc_binding_invalid', 'runtime credential response has an invalid API key')
|
|
727
|
-
await this.setRuntimeCredential(profile, session, credential.api_key, epoch)
|
|
728
|
-
await this.resources(profileID)
|
|
729
|
-
if (epoch !== this.accountEpoch(profile)) throw publicError('oidc_login_cancelled', '账户状态已改变,请重新登录。')
|
|
730
|
-
const current = await this.resolveBoundCredential(profile.id, {
|
|
731
|
-
credentialRef: profile.keyBinding.credentialRef, runtimeBaseURL: profile.provider.baseURL,
|
|
732
|
-
})
|
|
733
|
-
return account(profile, session, Boolean(current?.value), current ? credential.status ?? 'active' : 'replaced')
|
|
556
|
+
const session = await this.activeSession(profile)
|
|
557
|
+
return account(profile, session, false, 'not_required')
|
|
734
558
|
}
|
|
735
559
|
|
|
736
560
|
resources(profileID) {
|
|
@@ -742,32 +566,7 @@ export class WebOidcBackend {
|
|
|
742
566
|
}
|
|
743
567
|
|
|
744
568
|
async readResources(profileID) {
|
|
745
|
-
|
|
746
|
-
const epoch = this.accountEpoch(profile)
|
|
747
|
-
const result = emptyResources(profile)
|
|
748
|
-
if (!profile.keyBinding) return result
|
|
749
|
-
const session = await this.loadSession(profile)
|
|
750
|
-
const credential = await this.boundCredential(profile, session)
|
|
751
|
-
if (!credential?.value || epoch !== this.accountEpoch(profile)) return result
|
|
752
|
-
const request = async path => responseJSON(await this.fetch(`${profile.provider.baseURL}/${path}`, {
|
|
753
|
-
headers: { authorization: `Bearer ${credential.value}`, accept: 'application/json' },
|
|
754
|
-
signal: AbortSignal.timeout(20_000), redirect: 'error',
|
|
755
|
-
}), `oidc_resource_${path}_failed`)
|
|
756
|
-
const operations = []
|
|
757
|
-
if (profile.provider.modelSource === 'discovery') operations.push((async () => {
|
|
758
|
-
try {
|
|
759
|
-
const models = normalizeResourceModels(await request('models'), this.reviewedProfiles.get(profileID))
|
|
760
|
-
if (epoch !== this.accountEpoch(profile) || (await this.ctx.credentials.resolve(profile.keyBinding.credentialRef))?.value !== credential.value) return
|
|
761
|
-
const next = Object.freeze({ ...profile, provider: Object.freeze({ ...profile.provider, models }) })
|
|
762
|
-
await this.updateProvider(next)
|
|
763
|
-
this.profiles.set(profileID, next)
|
|
764
|
-
result.models = models
|
|
765
|
-
} catch { result.issues.push('models_unavailable') }
|
|
766
|
-
})())
|
|
767
|
-
await Promise.all(operations)
|
|
768
|
-
if (epoch !== this.accountEpoch(profile)) return emptyResources(profile)
|
|
769
|
-
this.resourceStates.set(profileID, result)
|
|
770
|
-
return result
|
|
569
|
+
return emptyResources(this.profile(profileID))
|
|
771
570
|
}
|
|
772
571
|
|
|
773
572
|
management() { return Promise.resolve(profileManagement(this.profiles)) }
|
|
@@ -805,101 +604,4 @@ export class WebOidcBackend {
|
|
|
805
604
|
}
|
|
806
605
|
}
|
|
807
606
|
|
|
808
|
-
export class NativeOidcBackend {
|
|
809
|
-
constructor(ctx, profiles) {
|
|
810
|
-
this.ctx = ctx
|
|
811
|
-
this.profiles = profiles
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
profile(id) {
|
|
815
|
-
const profile = this.profiles.get(id)
|
|
816
|
-
if (!profile) throw publicError('oidc_profile_unknown', `unknown OIDC profile ${id}`)
|
|
817
|
-
return profile
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
service() {
|
|
821
|
-
const service = this.ctx.get('enterpriseAccounts')
|
|
822
|
-
if (!service) throw publicError('oidc_native_adapter_unavailable', 'native OIDC adapter is unavailable')
|
|
823
|
-
return service
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
normalize(profile, status) {
|
|
827
|
-
if (status.runtimeCredentialRef !== undefined && status.runtimeCredentialRef !== (profile.keyBinding?.credentialRef ?? '')) {
|
|
828
|
-
throw publicError(
|
|
829
|
-
'oidc_native_credential_ref_mismatch',
|
|
830
|
-
`native OIDC adapter credential reference does not match Enterprise Profile ${profile.id}`,
|
|
831
|
-
)
|
|
832
|
-
}
|
|
833
|
-
return {
|
|
834
|
-
profileID: profile.id,
|
|
835
|
-
displayName: status.displayName ?? profile.displayName,
|
|
836
|
-
organization: status.organization ?? profile.organization,
|
|
837
|
-
state: status.state ?? (status.credentialReady ? 'connected' : 'signed_out'),
|
|
838
|
-
...(status.userName ? { userName: status.userName } : {}),
|
|
839
|
-
...(status.affiliation ? { affiliation: status.affiliation } : {}),
|
|
840
|
-
...(status.accessExpiresAt ? { accessExpiresAt: status.accessExpiresAt } : {}),
|
|
841
|
-
credentialRef: profile.keyBinding?.credentialRef ?? '',
|
|
842
|
-
credentialReady: status.credentialReady === true,
|
|
843
|
-
...(status.credentialState ? { credentialState: status.credentialState } : {}),
|
|
844
|
-
capabilities: Array.isArray(status.capabilities) ? status.capabilities : [],
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
normalizeManagement(configuration) {
|
|
849
|
-
const active = String(configuration?.activeInstitutionID ?? '')
|
|
850
|
-
const institutions = Array.isArray(configuration?.institutions) ? configuration.institutions : []
|
|
851
|
-
return {
|
|
852
|
-
schemaVersion: 'dsh-oidc/management/v1alpha1', mode: 'native', activeProfileID: active,
|
|
853
|
-
restartRequired: configuration?.restartRequired === true,
|
|
854
|
-
capabilities: {
|
|
855
|
-
manageProfiles: ['activate', 'configure', 'addCustom', 'updateCustom', 'removeInstitution'].every(method => typeof this.service()[method] === 'function'),
|
|
856
|
-
manageModels: typeof this.service().configureCustomModels === 'function',
|
|
857
|
-
restart: typeof this.service().restart === 'function',
|
|
858
|
-
},
|
|
859
|
-
profiles: institutions.map(institution => ({
|
|
860
|
-
id: String(institution.id), displayName: String(institution.displayName), organization: String(institution.organization),
|
|
861
|
-
baseURL: String(institution.baseURL), builtIn: institution.builtIn === true, configured: institution.configured === true,
|
|
862
|
-
enabled: String(institution.id) === active, providerID: String(institution.providerID),
|
|
863
|
-
runtime: runtimeProjection(institution.runtime, { displayName: institution.displayName }),
|
|
864
|
-
})),
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
async management() { return this.normalizeManagement(await this.service().configuration()) }
|
|
869
|
-
async resources(profileID) {
|
|
870
|
-
return emptyResources(this.profile(profileID))
|
|
871
|
-
}
|
|
872
|
-
async activate(profileID) { return this.normalizeManagement(await this.service().activate(profileID)) }
|
|
873
|
-
async configure(profileID) { return this.normalizeManagement(await this.service().configure(profileID)) }
|
|
874
|
-
async addCustom(baseURL) { return this.normalizeManagement(await this.service().addCustom(baseURL)) }
|
|
875
|
-
async updateCustom(profileID, baseURL) { return this.normalizeManagement(await this.service().updateCustom(profileID, baseURL)) }
|
|
876
|
-
async removeProfile(profileID) { return this.normalizeManagement(await this.service().removeInstitution(profileID)) }
|
|
877
|
-
async configureModels(profileID, modelMode, models) {
|
|
878
|
-
return this.normalizeManagement(await this.service().configureCustomModels(profileID, modelMode, models))
|
|
879
|
-
}
|
|
880
|
-
restart() { return this.service().restart() }
|
|
881
|
-
|
|
882
|
-
async status(profileID) {
|
|
883
|
-
const profile = this.profile(profileID)
|
|
884
|
-
return this.normalize(profile, await this.service().status(profile.nativeInstitutionID))
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
async begin(profileID) {
|
|
888
|
-
const profile = this.profile(profileID)
|
|
889
|
-
const status = await this.service().login(profile.nativeInstitutionID, { allowProvision: false })
|
|
890
|
-
return { mode: 'completed', status: this.normalize(profile, status) }
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
async reconcile(profileID, options = {}) {
|
|
894
|
-
const profile = this.profile(profileID)
|
|
895
|
-
const status = await this.service().reconcile(profile.nativeInstitutionID, { allowProvision: options.allowProvision === true })
|
|
896
|
-
return this.normalize(profile, status)
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
async logout(profileID) {
|
|
900
|
-
const profile = this.profile(profileID)
|
|
901
|
-
return this.normalize(profile, await this.service().logout(profile.nativeInstitutionID))
|
|
902
|
-
}
|
|
903
|
-
}
|
|
904
|
-
|
|
905
607
|
export { account, publicError, sessionRef }
|