@eduwork/dsh-oidc 0.1.0 → 0.2.1
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 +40 -1
- package/CHANGELOG.md +40 -1
- package/README.en.md +23 -21
- package/README.md +17 -15
- package/THIRD_PARTY_NOTICES.en.md +7 -5
- package/THIRD_PARTY_NOTICES.md +7 -5
- package/cordis.patch.yml +2 -1
- package/docs/EDUWORK-MIGRATION.en.md +14 -2
- package/docs/EDUWORK-MIGRATION.md +14 -2
- package/docs/account-extensions.en.md +40 -0
- package/docs/account-extensions.md +42 -0
- package/docs/architecture.en.md +9 -9
- package/docs/architecture.md +11 -10
- package/docs/compatibility.en.md +11 -2
- package/docs/compatibility.md +11 -2
- package/docs/desktop-host.en.md +69 -0
- package/docs/desktop-host.md +79 -0
- package/docs/development.en.md +42 -0
- package/docs/development.md +42 -0
- package/docs/dsh-integration.en.md +23 -7
- package/docs/dsh-integration.md +23 -7
- package/docs/ecnu-reference.en.md +3 -3
- package/docs/ecnu-reference.md +3 -3
- package/docs/enterprise-profile.en.md +19 -11
- package/docs/enterprise-profile.md +18 -10
- package/docs/getting-started.en.md +12 -12
- package/docs/getting-started.md +16 -14
- package/docs/key-binding-protocol.en.md +2 -2
- package/docs/key-binding-protocol.md +2 -2
- package/docs/oidc-interoperability.en.md +3 -3
- package/docs/oidc-interoperability.md +2 -2
- package/docs/public-resource-protocol.en.md +51 -0
- package/docs/public-resource-protocol.md +50 -0
- package/docs/release-checklist.en.md +4 -2
- package/docs/release-checklist.md +4 -2
- package/docs/server-integration-contract.en.md +124 -8
- package/docs/server-integration-contract.md +136 -8
- package/examples/ecnu.enterprise-profile.example.json +2 -2
- package/examples/enterprise-profile.example.json +78 -77
- package/examples/identity-only.example.json +10 -0
- package/examples/resources-discovery.example.json +20 -0
- package/lib/callback-page.js +42 -0
- package/lib/client.js +935 -295
- package/lib/desktop-oidc.js +205 -0
- package/lib/index.js +148 -18
- package/lib/oidc.js +314 -58
- package/lib/profile.js +53 -24
- package/lib/provider/core.js +76 -15
- package/lib/provider/index.js +33 -20
- package/lib/provider/transform-adapter.js +31 -10
- package/lib/resources.js +23 -0
- package/lib/typert-schemas.js +34 -3
- package/lib/typert.host.js +42 -36
- package/lib/typert.remote-client.js +41 -35
- package/package.json +100 -29
- package/protocol/openapi.yaml +2 -2
- package/protocol/resources.openapi.yaml +51 -0
- package/schema/enterprise-profile.v1alpha1.schema.json +193 -188
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { createServer } from 'node:http'
|
|
2
|
+
import { randomBytes } from 'node:crypto'
|
|
3
|
+
import { WebOidcBackend, OIDC_CALLBACK_PATH, sessionRef } from './oidc.js'
|
|
4
|
+
import { callbackLanguage, callbackPage } from './callback-page.js'
|
|
5
|
+
|
|
6
|
+
const MAX_FLOWS = 32
|
|
7
|
+
const MAX_HISTORY = 128
|
|
8
|
+
const error = (code, message) => Object.assign(new Error(message), { code })
|
|
9
|
+
const safeCodes = new Set(['oidc_callback_invalid', 'oidc_authorization_rejected', 'oidc_token_invalid', 'oidc_id_token_invalid', 'oidc_userinfo_invalid'])
|
|
10
|
+
|
|
11
|
+
function reply(response, status, profile, outcome, language) {
|
|
12
|
+
const page = callbackPage(profile, outcome, language)
|
|
13
|
+
response.writeHead(status, page.headers)
|
|
14
|
+
response.end(page.html)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Same identity/resource implementation as Web; only the callback transport differs. */
|
|
18
|
+
export class DesktopOidcBackend extends WebOidcBackend {
|
|
19
|
+
constructor(ctx, profiles, config = {}, options = {}) {
|
|
20
|
+
super(ctx, profiles, config, { ...options, transport: 'desktop' })
|
|
21
|
+
if (Object.keys(config).some(key => !['callbackPort', 'flowTimeoutMs'].includes(key))) throw new Error('unsupported desktop OIDC callback setting')
|
|
22
|
+
this.port = config.callbackPort ?? 0
|
|
23
|
+
this.ttl = config.flowTimeoutMs ?? 600_000
|
|
24
|
+
if (!Number.isInteger(this.port) || this.port < 0 || this.port > 65535) throw new Error('desktop callbackPort must be an integer from 0 to 65535')
|
|
25
|
+
if (!Number.isInteger(this.ttl) || this.ttl < 1000 || this.ttl > 600_000) throw new Error('desktop flowTimeoutMs must be between 1000 and 600000')
|
|
26
|
+
this.openExternal = options.openExternal
|
|
27
|
+
this.attempts = new Map()
|
|
28
|
+
this.starting = new Set()
|
|
29
|
+
this.disposed = false
|
|
30
|
+
ctx.effect(() => () => this.dispose(), 'dsh-oidc: temporary desktop callbacks')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async begin(profileID) {
|
|
34
|
+
const profile = this.profile(profileID)
|
|
35
|
+
if (this.disposed) throw error('oidc_host_unavailable', 'OIDC host has stopped')
|
|
36
|
+
if (typeof this.openExternal !== 'function') throw error('oidc_browser_unavailable', 'The desktop browser service is unavailable')
|
|
37
|
+
if (this.starting.has(profileID)) throw error('oidc_login_pending', 'Organization sign-in is already starting')
|
|
38
|
+
this.starting.add(profileID)
|
|
39
|
+
let attempt
|
|
40
|
+
try {
|
|
41
|
+
for (const previous of this.attempts.values()) if (previous.profileID === profileID && previous.state === 'pending') await this.cancelLogin(previous.loginID)
|
|
42
|
+
const discovery = await this.discover(profile)
|
|
43
|
+
if (this.disposed) throw error('oidc_host_unavailable', 'OIDC host has stopped')
|
|
44
|
+
if ([...this.attempts.values()].filter(value => value.state === 'pending').length >= MAX_FLOWS) throw error('oidc_flow_limit', 'Too many pending organization sign-ins')
|
|
45
|
+
const loginID = randomBytes(24).toString('base64url')
|
|
46
|
+
attempt = { loginID, profileID, state: 'pending', expiresAt: new Date(this.now() + this.ttl).toISOString(), consumed: false }
|
|
47
|
+
const server = createServer((request, response) => { void this.callbackFor(attempt, request, response) })
|
|
48
|
+
server.requestTimeout = 10_000
|
|
49
|
+
server.headersTimeout = 10_000
|
|
50
|
+
server.maxHeadersCount = 32
|
|
51
|
+
attempt.server = server
|
|
52
|
+
this.attempts.set(loginID, attempt)
|
|
53
|
+
await new Promise((resolve, reject) => {
|
|
54
|
+
server.once('error', reject)
|
|
55
|
+
server.listen({ host: '127.0.0.1', port: this.port, exclusive: true }, resolve)
|
|
56
|
+
}).catch(() => { throw error('oidc_callback_unavailable', 'The local sign-in callback could not start; check whether its configured port is in use') })
|
|
57
|
+
server.unref()
|
|
58
|
+
if (this.disposed || attempt.state !== 'pending') {
|
|
59
|
+
await new Promise(resolve => { server.close(resolve); server.closeAllConnections() })
|
|
60
|
+
throw error('oidc_host_unavailable', 'OIDC host has stopped')
|
|
61
|
+
}
|
|
62
|
+
attempt.origin = `http://127.0.0.1:${server.address().port}`
|
|
63
|
+
const authorization = this.createAuthorization(profile, discovery, `${attempt.origin}${OIDC_CALLBACK_PATH}`)
|
|
64
|
+
Object.assign(attempt, { oauthState: authorization.state, flow: authorization.flow })
|
|
65
|
+
attempt.timer = setTimeout(() => { void this.cancelLogin(loginID, 'expired').catch(() => {}) }, this.ttl)
|
|
66
|
+
attempt.timer.unref()
|
|
67
|
+
// This Host-only method receives a URL derived from reviewed discovery, never an RPC URL.
|
|
68
|
+
try { await this.openExternal(authorization.authorizationURL) }
|
|
69
|
+
catch { throw error('oidc_browser_unavailable', 'The sign-in browser could not be opened') }
|
|
70
|
+
return { mode: 'external', loginID, expiresAt: attempt.expiresAt }
|
|
71
|
+
} catch (cause) {
|
|
72
|
+
if (attempt) await this.cancelLogin(attempt.loginID, 'failed')
|
|
73
|
+
throw cause
|
|
74
|
+
} finally {
|
|
75
|
+
this.starting.delete(profileID)
|
|
76
|
+
this.pruneAttempts()
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
pruneAttempts() {
|
|
81
|
+
for (const [id, attempt] of this.attempts) {
|
|
82
|
+
if (attempt.state !== 'pending' && (this.attempts.size > MAX_HISTORY || Date.parse(attempt.expiresAt) + this.ttl < this.now())) this.attempts.delete(id)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async closeCallback(attempt, force = false) {
|
|
87
|
+
clearTimeout(attempt.timer)
|
|
88
|
+
this.flows.delete(attempt.oauthState)
|
|
89
|
+
if (!attempt.server) return
|
|
90
|
+
const server = attempt.server
|
|
91
|
+
attempt.server = undefined
|
|
92
|
+
const closed = new Promise(resolve => server.close(resolve))
|
|
93
|
+
if (force) server.closeAllConnections()
|
|
94
|
+
else server.closeIdleConnections()
|
|
95
|
+
await closed
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
loginStatus(loginID) {
|
|
99
|
+
const attempt = this.attempts.get(loginID)
|
|
100
|
+
if (!attempt) throw error('oidc_login_unknown', 'This sign-in attempt is no longer available')
|
|
101
|
+
return { loginID, profileID: attempt.profileID, state: attempt.state, expiresAt: attempt.expiresAt,
|
|
102
|
+
...(attempt.status ? { status: attempt.status } : {}), ...(attempt.errorCode ? { errorCode: attempt.errorCode } : {}) }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async cancelLogin(loginID, state = 'cancelled') {
|
|
106
|
+
const attempt = this.attempts.get(loginID)
|
|
107
|
+
if (!attempt) throw error('oidc_login_unknown', 'This sign-in attempt is no longer available')
|
|
108
|
+
if (attempt.state === 'pending') {
|
|
109
|
+
attempt.state = state
|
|
110
|
+
await this.closeCallback(attempt, true)
|
|
111
|
+
}
|
|
112
|
+
await attempt.processing
|
|
113
|
+
return this.loginStatus(loginID)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async callbackFor(attempt, request, response) {
|
|
117
|
+
const profile = this.profile(attempt.profileID), language = callbackLanguage(request.headers['accept-language'])
|
|
118
|
+
let requested
|
|
119
|
+
try {
|
|
120
|
+
requested = new URL(request.url, attempt.origin)
|
|
121
|
+
if (request.method !== 'GET' || request.headers.host !== new URL(attempt.origin).host
|
|
122
|
+
|| (request.headers.origin && request.headers.origin !== attempt.origin)
|
|
123
|
+
|| requested.origin !== attempt.origin || requested.username || requested.password || requested.hash || requested.pathname !== OIDC_CALLBACK_PATH
|
|
124
|
+
|| requested.searchParams.getAll('state').length !== 1 || requested.searchParams.get('state') !== attempt.oauthState
|
|
125
|
+
|| attempt.state !== 'pending' || attempt.consumed) {
|
|
126
|
+
reply(response, 400, profile, 'failed', language); return
|
|
127
|
+
}
|
|
128
|
+
if (Date.parse(attempt.expiresAt) <= this.now()) {
|
|
129
|
+
reply(response, 410, profile, 'expired', language); await this.cancelLogin(attempt.loginID, 'expired'); return
|
|
130
|
+
}
|
|
131
|
+
} catch { reply(response, 400, profile, 'failed', language); return }
|
|
132
|
+
attempt.consumed = true
|
|
133
|
+
this.flows.delete(attempt.oauthState)
|
|
134
|
+
attempt.processing = this.finishAuthorization(attempt, requested).catch(() => {
|
|
135
|
+
attempt.state = 'failed'
|
|
136
|
+
attempt.errorCode = 'oidc_credentials_unavailable'
|
|
137
|
+
this.ctx.logger.warn('Desktop OIDC host credentials are unavailable')
|
|
138
|
+
})
|
|
139
|
+
await attempt.processing
|
|
140
|
+
if (!response.destroyed) reply(response, attempt.state === 'completed' ? 200 : 400, profile,
|
|
141
|
+
attempt.state === 'completed' ? (attempt.status?.state === 'authenticated' ? 'credential-required' : 'completed') : 'failed', language)
|
|
142
|
+
await this.closeCallback(attempt)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async finishAuthorization(attempt, requested) {
|
|
146
|
+
const profile = this.profile(attempt.profileID)
|
|
147
|
+
const previous = new Map()
|
|
148
|
+
let wrote = false
|
|
149
|
+
try {
|
|
150
|
+
const session = await this.exchangeAuthorization(requested, attempt.flow)
|
|
151
|
+
if (attempt.state !== 'pending') return
|
|
152
|
+
const refs = [sessionRef(profile), ...(profile.keyBinding ? [profile.keyBinding.credentialRef] : [])]
|
|
153
|
+
for (const ref of refs) previous.set(ref, await this.ctx.credentials.resolve(ref))
|
|
154
|
+
if (attempt.state !== 'pending') return
|
|
155
|
+
wrote = true
|
|
156
|
+
await this.saveSession(profile, session, attempt.flow.epoch)
|
|
157
|
+
if (attempt.state !== 'pending') return
|
|
158
|
+
// A resource outage must not turn a successful identity login into a failed login.
|
|
159
|
+
let status
|
|
160
|
+
try { status = await this.reconcile(profile.id, { allowProvision: false }) }
|
|
161
|
+
catch { status = await this.status(profile.id) }
|
|
162
|
+
if (attempt.state !== 'pending') return
|
|
163
|
+
attempt.status = status
|
|
164
|
+
attempt.state = 'completed'
|
|
165
|
+
this.accountChanged(status)
|
|
166
|
+
} catch (cause) {
|
|
167
|
+
if (attempt.state === 'pending') {
|
|
168
|
+
attempt.state = 'failed'
|
|
169
|
+
attempt.errorCode = safeCodes.has(cause?.code) ? cause.code : 'oidc_login_failed'
|
|
170
|
+
}
|
|
171
|
+
this.ctx.logger.warn('Desktop OIDC sign-in did not complete', { code: attempt.errorCode ?? 'oidc_login_cancelled' })
|
|
172
|
+
} finally {
|
|
173
|
+
// Cancellation/disposal while the host vault writes must not resurrect a login.
|
|
174
|
+
if (wrote && attempt.state !== 'completed') {
|
|
175
|
+
await this.writeCredentials(profile, attempt.flow.epoch, async () => {
|
|
176
|
+
const currentRecord = await this.ctx.credentials.resolve(sessionRef(profile))
|
|
177
|
+
let currentSession
|
|
178
|
+
try { currentSession = JSON.parse(currentRecord?.value) } catch { /* No proven runtime-key ownership. */ }
|
|
179
|
+
const ownsCurrentKey = await this.boundCredential(profile, currentSession)
|
|
180
|
+
for (const [ref, record] of previous) {
|
|
181
|
+
// A different profile may have rebound the shared key during login.
|
|
182
|
+
if (ref !== sessionRef(profile) && !ownsCurrentKey) continue
|
|
183
|
+
if (record?.value !== undefined) await this.ctx.credentials.set(ref, record.value)
|
|
184
|
+
else await this.ctx.credentials.unset(ref)
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
this.resourceStates.delete(profile.id)
|
|
188
|
+
}
|
|
189
|
+
// Retain only the safe public result once the authorization is consumed.
|
|
190
|
+
attempt.flow = undefined
|
|
191
|
+
attempt.oauthState = undefined
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async logout(profileID) {
|
|
196
|
+
for (const attempt of this.attempts.values()) if (attempt.profileID === profileID && attempt.state === 'pending') await this.cancelLogin(attempt.loginID)
|
|
197
|
+
return super.logout(profileID)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async dispose() {
|
|
201
|
+
this.disposed = true
|
|
202
|
+
await Promise.all([...this.attempts.values()].filter(value => value.state === 'pending').map(value => this.cancelLogin(value.loginID)))
|
|
203
|
+
await Promise.all([...this.attempts.values()].map(value => value.processing))
|
|
204
|
+
}
|
|
205
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
|
2
2
|
import { apply as applyEnterpriseProvider } from './provider/index.js'
|
|
3
3
|
import { NativeOidcBackend, WebOidcBackend } from './oidc.js'
|
|
4
|
+
import { DesktopOidcBackend } from './desktop-oidc.js'
|
|
4
5
|
import { enterpriseProviderConfig, loadEnterpriseProfiles, publicProfile } from './profile.js'
|
|
5
6
|
|
|
6
7
|
export const name = 'dsh-oidc'
|
|
7
8
|
const remoteInitializers = []
|
|
8
9
|
|
|
9
10
|
export class OidcAccountService extends TypertRemoteService {
|
|
10
|
-
static inject = ['credentials', 'llm'
|
|
11
|
+
static inject = ['credentials', 'llm']
|
|
11
12
|
|
|
12
13
|
constructor(ctx, config = {}) {
|
|
13
14
|
super(ctx, 'oidcAccounts')
|
|
@@ -15,36 +16,164 @@ export class OidcAccountService extends TypertRemoteService {
|
|
|
15
16
|
this.profiles = loadEnterpriseProfiles(config)
|
|
16
17
|
if (this.profiles.size === 0 && config.allowEmptyProfiles !== true) throw new Error('dsh-oidc requires at least one Enterprise Profile')
|
|
17
18
|
this.uiMode = ['external', 'models-only'].includes(config.uiMode) ? config.uiMode : 'standard'
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
this.manageProductBrand = config.manageProductBrand !== false
|
|
20
|
+
if (config.configFile !== undefined) {
|
|
21
|
+
const source = config.configFile
|
|
22
|
+
if (!source || typeof source.path !== 'string' || !source.path || typeof source.examplesPath !== 'string' || !source.examplesPath) throw new Error('Invalid enterprise configuration file location')
|
|
23
|
+
this.configFile = { path: source.path, examplesPath: source.examplesPath }
|
|
24
|
+
ctx.inject(['desktopServices'], inner => {
|
|
25
|
+
if (typeof inner.desktopServices.openConfiguration !== 'function') return
|
|
26
|
+
const open = target => inner.desktopServices.openConfiguration(target)
|
|
27
|
+
this.openConfigTarget = open
|
|
28
|
+
inner.effect(() => () => {
|
|
29
|
+
if (this.openConfigTarget === open) this.openConfigTarget = undefined
|
|
30
|
+
}, 'dsh-oidc: configuration file service')
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
this.accountStates = new Map()
|
|
34
|
+
const provider = applyEnterpriseProvider(ctx, {
|
|
35
|
+
...enterpriseProviderConfig(this.profiles),
|
|
36
|
+
resolveCredential: async providerID => {
|
|
37
|
+
const profile = [...this.profiles.values()].find(value => value.provider?.id === providerID)
|
|
38
|
+
if (!profile) return undefined
|
|
39
|
+
if (this.backend instanceof NativeOidcBackend) return ctx.credentials.resolve(profile.keyBinding.credentialRef)
|
|
40
|
+
if (this.backend instanceof WebOidcBackend) return this.backend.resolveBoundCredential(profile.id, {
|
|
41
|
+
credentialRef: profile.keyBinding.credentialRef, runtimeBaseURL: profile.provider.baseURL,
|
|
42
|
+
})
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
const backendOptions = {
|
|
46
|
+
authorizedOrigins: config.authorizedOrigins,
|
|
47
|
+
accountChanged: status => this.notifyAccount(status),
|
|
48
|
+
updateProvider: profile => {
|
|
49
|
+
const profiles = new Map(this.profiles)
|
|
50
|
+
profiles.set(profile.id, profile)
|
|
51
|
+
provider.replaceConfig(enterpriseProviderConfig(profiles))
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
if (config.backend === 'native') this.backend = new NativeOidcBackend(ctx, this.profiles)
|
|
55
|
+
else if (config.backend === 'desktop') {
|
|
56
|
+
this.backend = new DesktopOidcBackend(ctx, this.profiles, config.desktop ?? {}, backendOptions)
|
|
57
|
+
ctx.inject(['desktopServices'], inner => {
|
|
58
|
+
this.backend.openExternal = url => inner.desktopServices.openExternal(url)
|
|
59
|
+
inner.effect(() => () => {
|
|
60
|
+
this.backend.openExternal = undefined
|
|
61
|
+
return Promise.all([...this.backend.attempts.values()].filter(value => value.state === 'pending').map(value => this.backend.cancelLogin(value.loginID)))
|
|
62
|
+
}, 'dsh-oidc: external browser service')
|
|
63
|
+
})
|
|
64
|
+
} else {
|
|
65
|
+
this.backendReady = new Promise(resolve => {
|
|
66
|
+
ctx.inject(['webServer'], inner => {
|
|
67
|
+
const backend = new WebOidcBackend(inner, this.profiles, config.web ?? {}, backendOptions)
|
|
68
|
+
this.backend = backend
|
|
69
|
+
resolve()
|
|
70
|
+
inner.effect(() => () => { if (this.backend === backend) this.backend = undefined }, 'dsh-oidc: web backend')
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async callBackend(method, ...args) {
|
|
77
|
+
if (this.configFile && ['activate', 'configure', 'addCustom', 'updateCustom', 'removeProfile', 'configureModels'].includes(method)) throw new Error('Organization services are managed in the configuration file; edit it and restart the application')
|
|
78
|
+
await this.backendReady
|
|
79
|
+
if (typeof this.backend?.[method] !== 'function') throw new Error('This OIDC operation is unavailable in the configured host')
|
|
80
|
+
return this.backend[method](...args)
|
|
22
81
|
}
|
|
23
82
|
|
|
24
83
|
configuration() {
|
|
25
84
|
return Promise.resolve({
|
|
26
85
|
schemaVersion: 'dsh-oidc/v1alpha1',
|
|
27
86
|
uiMode: this.uiMode,
|
|
87
|
+
manageProductBrand: this.manageProductBrand,
|
|
88
|
+
...(this.configFile ? { configFile: { ...this.configFile, canOpen: typeof this.openConfigTarget === 'function' } } : {}),
|
|
28
89
|
profiles: [...this.profiles.values()].map(publicProfile),
|
|
29
90
|
})
|
|
30
91
|
}
|
|
31
92
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
93
|
+
async openConfiguration(target) {
|
|
94
|
+
if (!['config', 'examples'].includes(target)) throw new Error('只能打开应用配置文件或示例目录。')
|
|
95
|
+
if (!this.configFile || typeof this.openConfigTarget !== 'function') throw new Error('当前宿主不支持直接打开配置文件,请按配置路径手动打开。')
|
|
96
|
+
try { await this.openConfigTarget(target) }
|
|
97
|
+
catch { throw new Error('未能打开配置文件或示例目录,请重试或检查系统文件关联。') }
|
|
98
|
+
return { opened: true }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
notifyAccount(status) {
|
|
102
|
+
if (this.accountStates.get(status.profileID) !== status.state) {
|
|
103
|
+
this.accountStates.set(status.profileID, status.state)
|
|
104
|
+
this.ctx.emit('oidc/accounts-changed', { profileID: status.profileID, state: status.state })
|
|
105
|
+
}
|
|
106
|
+
return status
|
|
107
|
+
}
|
|
108
|
+
async status(profileID) { return this.notifyAccount(await this.callBackend('status', profileID)) }
|
|
109
|
+
resources(profileID) { return this.callBackend('resources', profileID) }
|
|
110
|
+
// Host-only capability: deliberately absent from Remote markers and Typert.
|
|
111
|
+
authorizedFetch(profileID, endpoint, init) {
|
|
112
|
+
return this.callBackend('authorizedFetch', profileID, endpoint, init)
|
|
113
|
+
}
|
|
114
|
+
modelResourceFetch(profileID, relativePath, options) {
|
|
115
|
+
return this.callBackend('modelResourceFetch', profileID, relativePath, options)
|
|
116
|
+
}
|
|
117
|
+
// Host-only credential snapshot for optional adapters; never exposed via RPC.
|
|
118
|
+
async resolveBoundCredential(profileID, expected) {
|
|
119
|
+
await this.backendReady
|
|
120
|
+
return this.backend instanceof WebOidcBackend ? this.backend.resolveBoundCredential(profileID, expected) : undefined
|
|
121
|
+
}
|
|
122
|
+
async begin(profileID) {
|
|
123
|
+
const result = await this.callBackend('begin', profileID)
|
|
124
|
+
if (result.mode === 'completed') this.notifyAccount(result.status)
|
|
125
|
+
return result
|
|
126
|
+
}
|
|
127
|
+
loginStatus(loginID) { return this.callBackend('loginStatus', loginID) }
|
|
128
|
+
cancelLogin(loginID) { return this.callBackend('cancelLogin', loginID) }
|
|
129
|
+
async reconcile(profileID, options) { return this.notifyAccount(await this.callBackend('reconcile', profileID, options)) }
|
|
130
|
+
async selectEnterpriseModel(profileID, options = {}) {
|
|
131
|
+
const status = await this.callBackend('status', profileID)
|
|
132
|
+
const profile = this.profiles.get(profileID)
|
|
133
|
+
if (!profile?.provider || status.state !== 'connected' || !status.credentialReady) throw new Error('请先完成企业登录和模型凭据连接,再选择企业模型。')
|
|
134
|
+
const defaults = this.ctx.get('agentDefaultModel')
|
|
135
|
+
if (typeof defaults?.saveSelection !== 'function') throw new Error('已连接企业模型;当前宿主不支持自动选择,请从模型菜单中选择。')
|
|
136
|
+
const original = defaults.currentSelection()
|
|
137
|
+
if (options.onlyIfMissing === true) {
|
|
138
|
+
const current = original
|
|
139
|
+
if (current?.provider && current?.model) {
|
|
140
|
+
try { await this.ctx.llm.resolveCallConfig(current); return { changed: false } }
|
|
141
|
+
catch { /* The old edition's provider may no longer be assembled. */ }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const models = await this.ctx.llm.listModels(profile.provider.id)
|
|
145
|
+
for (const model of models) {
|
|
146
|
+
let resolved
|
|
147
|
+
try { resolved = await this.ctx.llm.resolveCallConfig({ provider: profile.provider.id, model: model.id }) }
|
|
148
|
+
catch { continue }
|
|
149
|
+
const selection = { provider: resolved.provider, model: resolved.model,
|
|
150
|
+
...(resolved.reasoningEffort === undefined ? {} : { reasoningEffort: String(resolved.reasoningEffort) }) }
|
|
151
|
+
// A slow discovery must not overwrite a newer personal choice or finish
|
|
152
|
+
// activation after the account has been signed out elsewhere.
|
|
153
|
+
if (options.onlyIfMissing === true && JSON.stringify(defaults.currentSelection()) !== JSON.stringify(original)) return { changed: false }
|
|
154
|
+
const latest = await this.callBackend('status', profileID)
|
|
155
|
+
if (latest.state !== 'connected' || !latest.credentialReady) throw new Error('企业连接状态已变化,请重新检查连接后选择模型。')
|
|
156
|
+
await defaults.saveSelection(selection)
|
|
157
|
+
return { selection, changed: true }
|
|
158
|
+
}
|
|
159
|
+
throw new Error('企业登录已完成,但尚未找到可用模型。请刷新模型目录或联系服务管理员。')
|
|
160
|
+
}
|
|
161
|
+
async logout(profileID) { return this.notifyAccount(await this.callBackend('logout', profileID)) }
|
|
162
|
+
async management() {
|
|
163
|
+
const result = await this.callBackend('management')
|
|
164
|
+
return this.configFile ? { ...result, capabilities: { ...result.capabilities, manageProfiles: false, manageModels: false } } : result
|
|
165
|
+
}
|
|
166
|
+
activate(profileID) { return this.callBackend('activate', profileID) }
|
|
167
|
+
configure(profileID) { return this.callBackend('configure', profileID) }
|
|
168
|
+
addCustom(baseURL) { return this.callBackend('addCustom', baseURL) }
|
|
169
|
+
updateCustom(profileID, baseURL) { return this.callBackend('updateCustom', profileID, baseURL) }
|
|
170
|
+
removeProfile(profileID) { return this.callBackend('removeProfile', profileID) }
|
|
171
|
+
configureModels(profileID, modelMode, models) { return this.callBackend('configureModels', profileID, modelMode, models) }
|
|
172
|
+
restart() { return this.callBackend('restart') }
|
|
44
173
|
}
|
|
45
174
|
|
|
46
175
|
for (const method of [
|
|
47
|
-
'configuration', 'status', 'begin', 'reconcile', 'logout', 'management',
|
|
176
|
+
'configuration', 'openConfiguration', 'status', 'resources', 'begin', 'loginStatus', 'cancelLogin', 'reconcile', 'selectEnterpriseModel', 'logout', 'management',
|
|
48
177
|
'activate', 'configure', 'addCustom', 'updateCustom', 'removeProfile', 'configureModels', 'restart',
|
|
49
178
|
]) {
|
|
50
179
|
Remote(method)(OidcAccountService.prototype[method], {
|
|
@@ -56,4 +185,5 @@ for (const method of [
|
|
|
56
185
|
export default OidcAccountService
|
|
57
186
|
export { enterpriseProviderConfig, loadEnterpriseProfiles, normalizeEnterpriseProfile, publicProfile } from './profile.js'
|
|
58
187
|
export { NativeOidcBackend, WebOidcBackend } from './oidc.js'
|
|
188
|
+
export { DesktopOidcBackend } from './desktop-oidc.js'
|
|
59
189
|
export { EnterpriseModelTransforms } from './provider/transforms.js'
|