@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.
- package/CHANGELOG.en.md +120 -0
- package/CHANGELOG.md +120 -0
- package/LICENSE +21 -0
- package/README.en.md +184 -0
- package/README.md +143 -0
- package/THIRD_PARTY_NOTICES.en.md +51 -0
- package/THIRD_PARTY_NOTICES.md +53 -0
- package/cordis.patch.yml +8 -0
- package/docs/EDUWORK-MIGRATION.en.md +23 -0
- package/docs/EDUWORK-MIGRATION.md +23 -0
- package/docs/architecture.en.md +125 -0
- package/docs/architecture.md +125 -0
- package/docs/compatibility.en.md +61 -0
- package/docs/compatibility.md +61 -0
- package/docs/decisions/0001-one-plugin-composition-boundary.en.md +34 -0
- package/docs/decisions/0001-one-plugin-composition-boundary.md +34 -0
- package/docs/dsh-integration.en.md +171 -0
- package/docs/dsh-integration.md +171 -0
- package/docs/ecnu-reference.en.md +66 -0
- package/docs/ecnu-reference.md +66 -0
- package/docs/enterprise-profile.en.md +159 -0
- package/docs/enterprise-profile.md +159 -0
- package/docs/getting-started.en.md +137 -0
- package/docs/getting-started.md +239 -0
- package/docs/key-binding-protocol.en.md +227 -0
- package/docs/key-binding-protocol.md +227 -0
- package/docs/oidc-interoperability.en.md +100 -0
- package/docs/oidc-interoperability.md +100 -0
- package/docs/release-checklist.en.md +60 -0
- package/docs/release-checklist.md +60 -0
- package/docs/security-model.en.md +138 -0
- package/docs/security-model.md +138 -0
- package/docs/server-integration-contract.en.md +706 -0
- package/docs/server-integration-contract.md +706 -0
- package/examples/ecnu.enterprise-profile.example.json +81 -0
- package/examples/enterprise-profile.example.json +77 -0
- package/lib/client.js +5855 -0
- package/lib/index.js +59 -0
- package/lib/oidc.js +649 -0
- package/lib/profile.js +371 -0
- package/lib/provider/core.js +138 -0
- package/lib/provider/index.js +156 -0
- package/lib/provider/transform-adapter.js +65 -0
- package/lib/provider/transforms.js +59 -0
- package/lib/typert-schemas.js +84 -0
- package/lib/typert.host.js +38 -0
- package/lib/typert.remote-client.js +37 -0
- package/package.json +120 -0
- package/protocol/openapi.yaml +257 -0
- 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 }
|