@oimlsmart/platform-server 0.1.0
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/README.md +92 -0
- package/migrations/0001_init.sql +69 -0
- package/migrations/0002_identity.sql +24 -0
- package/migrations/0003_federation_peers.sql +21 -0
- package/migrations/0003_users_rbac.sql +8 -0
- package/migrations/0004_oidc_op.sql +61 -0
- package/migrations/0005_upstream_providers.sql +34 -0
- package/migrations/0006_op_accounts.sql +28 -0
- package/migrations/0007_org_join_requests.sql +26 -0
- package/migrations/0008_op_client_roles.sql +19 -0
- package/migrations/0009_account_console.sql +32 -0
- package/migrations/0009_sso_states.sql +13 -0
- package/migrations/0010_notify_events.sql +23 -0
- package/migrations/0011_op_launch.sql +18 -0
- package/migrations/0011_org_memberships.sql +62 -0
- package/migrations/0012_notify_subscriptions.sql +57 -0
- package/migrations/0012_strong_auth.sql +109 -0
- package/migrations/0013_org_registry.sql +53 -0
- package/migrations/0014_notify_inbox.sql +34 -0
- package/migrations/0015_certificate_holder_attribution.sql +63 -0
- package/migrations/0016_instrument_registrations.sql +78 -0
- package/package.json +52 -0
- package/src/client-info.ts +25 -0
- package/src/context.ts +31 -0
- package/src/github.ts +284 -0
- package/src/mailer.ts +309 -0
- package/src/oidc.ts +369 -0
- package/src/profile/node.ts +83 -0
- package/src/profile.ts +582 -0
- package/src/rbac/node.ts +42 -0
- package/src/rbac.ts +53 -0
- package/src/session.ts +45 -0
- package/src/store/d1.ts +2850 -0
- package/src/store/sqlite/entities.ts +71 -0
- package/src/store/sqlite/events.ts +82 -0
- package/src/store/sqlite/factors-store.ts +348 -0
- package/src/store/sqlite/notify.ts +247 -0
- package/src/store/sqlite/op-accounts-store.ts +470 -0
- package/src/store/sqlite/op-store.ts +280 -0
- package/src/store/sqlite/schema.sql +745 -0
- package/src/store/sqlite/store.ts +1390 -0
- package/src/store/sqlite/upstream-store.ts +148 -0
- package/src/store/sqlite.ts +1027 -0
- package/src/store.ts +1826 -0
- package/src/vocab/index.ts +12 -0
- package/src/vocab/permissions.ts +398 -0
- package/src/vocab/rbac.ts +281 -0
- package/src/vocab/roles.ts +162 -0
package/src/oidc.ts
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2
|
+
// The OIDC client (TODO.federation/10) — relying-party half of the
|
|
3
|
+
// Authorization Code + PKCE flow, hand-rolled on WebCrypto + fetch.
|
|
4
|
+
//
|
|
5
|
+
// NO LIBRARY DEPENDENCY, deliberately: every runtime this server runs
|
|
6
|
+
// on (node ≥ 18 via @hono/node-server, Cloudflare Workers) ships
|
|
7
|
+
// WebCrypto and fetch, and the platform already hand-rolls its GitHub
|
|
8
|
+
// OAuth flow the same way (routes/auth.ts). A general OIDC library
|
|
9
|
+
// (openid-client et al.) would add a node-centric dependency tree for
|
|
10
|
+
// ~300 lines of standard-conformant HTTP+JWT work we can test directly.
|
|
11
|
+
//
|
|
12
|
+
// What this module implements (OIDC Core 1.0):
|
|
13
|
+
// - issuer discovery (GET <issuer>/.well-known/openid-configuration,
|
|
14
|
+
// RFC 8414 location; the metadata's issuer MUST match exactly);
|
|
15
|
+
// - the authorization-request URL with PKCE (S256) + state + nonce;
|
|
16
|
+
// - the code exchange at the token endpoint (client_secret_basic when
|
|
17
|
+
// a secret is configured, public-client body auth otherwise);
|
|
18
|
+
// - ID-token validation: signature against the IdP's JWKS (RS256 and
|
|
19
|
+
// ES256), iss, aud (+ azp when multiple audiences), exp (60 s
|
|
20
|
+
// leeway), and the nonce we issued;
|
|
21
|
+
// - RP-initiated logout URL (OIDC RP-Initiated Logout 1.0) when the
|
|
22
|
+
// metadata declares end_session_endpoint.
|
|
23
|
+
//
|
|
24
|
+
// Failures raise OidcError with a machine `reason` — routes/auth.ts
|
|
25
|
+
// maps reasons to the plain-language sign-in error page, never a stack
|
|
26
|
+
// trace.
|
|
27
|
+
//
|
|
28
|
+
// WORKER-SAFE: WebCrypto + fetch only, no node built-ins.
|
|
29
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
30
|
+
|
|
31
|
+
// ── failure surface ─────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
export type OidcFailureReason =
|
|
34
|
+
| 'discovery' // the issuer's metadata could not be fetched/parsed
|
|
35
|
+
| 'issuer_mismatch' // metadata.issuer ≠ the configured issuer
|
|
36
|
+
| 'exchange' // the token endpoint refused the code exchange
|
|
37
|
+
| 'token_malformed' // the ID token is not a JWT we can parse
|
|
38
|
+
| 'token_alg' // the ID token uses an algorithm we do not verify
|
|
39
|
+
| 'token_signature' // signature verification failed / no matching key
|
|
40
|
+
| 'token_issuer' // iss ≠ the configured issuer
|
|
41
|
+
| 'token_audience' // aud/azp does not name our client
|
|
42
|
+
| 'token_expired' // exp is in the past (60 s leeway allowed)
|
|
43
|
+
| 'token_nonce' // nonce ≠ the one we issued (replay guard)
|
|
44
|
+
|
|
45
|
+
export class OidcError extends Error {
|
|
46
|
+
constructor(
|
|
47
|
+
readonly reason: OidcFailureReason,
|
|
48
|
+
message: string,
|
|
49
|
+
) {
|
|
50
|
+
super(message)
|
|
51
|
+
this.name = 'OidcError'
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── discovery ───────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
export interface OidcMetadata {
|
|
58
|
+
issuer: string
|
|
59
|
+
authorization_endpoint: string
|
|
60
|
+
token_endpoint: string
|
|
61
|
+
jwks_uri: string
|
|
62
|
+
/** RP-initiated logout — ABSENT when the IdP does not support it. */
|
|
63
|
+
end_session_endpoint?: string
|
|
64
|
+
userinfo_endpoint?: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface CachedMetadata { metadata: OidcMetadata; fetchedAt: number }
|
|
68
|
+
const metadataCache = new Map<string, CachedMetadata>()
|
|
69
|
+
const METADATA_TTL_MS = 60 * 60 * 1000
|
|
70
|
+
|
|
71
|
+
/** Discover (and cache) the issuer's metadata. The metadata's issuer
|
|
72
|
+
* MUST equal the configured issuer string exactly (mix-up guard). */
|
|
73
|
+
export async function discoverIssuer(issuer: string, fetchImpl: typeof fetch = fetch): Promise<OidcMetadata> {
|
|
74
|
+
const cached = metadataCache.get(issuer)
|
|
75
|
+
if (cached && Date.now() - cached.fetchedAt < METADATA_TTL_MS) return cached.metadata
|
|
76
|
+
|
|
77
|
+
const wellKnown = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`
|
|
78
|
+
let body: unknown
|
|
79
|
+
try {
|
|
80
|
+
const res = await fetchImpl(wellKnown)
|
|
81
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
82
|
+
body = await res.json()
|
|
83
|
+
} catch (err) {
|
|
84
|
+
throw new OidcError('discovery', `could not fetch ${wellKnown}: ${(err as Error).message}`)
|
|
85
|
+
}
|
|
86
|
+
const meta = body as Partial<OidcMetadata>
|
|
87
|
+
if (typeof meta?.issuer !== 'string' || typeof meta?.authorization_endpoint !== 'string'
|
|
88
|
+
|| typeof meta?.token_endpoint !== 'string' || typeof meta?.jwks_uri !== 'string') {
|
|
89
|
+
throw new OidcError('discovery', `the metadata at ${wellKnown} is incomplete (issuer/authorization_endpoint/token_endpoint/jwks_uri required)`)
|
|
90
|
+
}
|
|
91
|
+
if (meta.issuer.replace(/\/$/, '') !== issuer.replace(/\/$/, '')) {
|
|
92
|
+
throw new OidcError('issuer_mismatch', `the metadata declares issuer ${meta.issuer}, not ${issuer}`)
|
|
93
|
+
}
|
|
94
|
+
const metadata: OidcMetadata = {
|
|
95
|
+
issuer: meta.issuer,
|
|
96
|
+
authorization_endpoint: meta.authorization_endpoint,
|
|
97
|
+
token_endpoint: meta.token_endpoint,
|
|
98
|
+
jwks_uri: meta.jwks_uri,
|
|
99
|
+
...(typeof meta.end_session_endpoint === 'string' ? { end_session_endpoint: meta.end_session_endpoint } : {}),
|
|
100
|
+
...(typeof meta.userinfo_endpoint === 'string' ? { userinfo_endpoint: meta.userinfo_endpoint } : {}),
|
|
101
|
+
}
|
|
102
|
+
metadataCache.set(issuer, { metadata, fetchedAt: Date.now() })
|
|
103
|
+
return metadata
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Test hook: drop the cached metadata + JWKS (the e2e stub rotates). */
|
|
107
|
+
export function clearOidcCaches(): void {
|
|
108
|
+
metadataCache.clear()
|
|
109
|
+
jwksCache.clear()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── PKCE + one-time values ──────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
function base64url(bytes: Uint8Array): string {
|
|
115
|
+
let bin = ''
|
|
116
|
+
for (const b of bytes) bin += String.fromCharCode(b)
|
|
117
|
+
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function base64urlDecode(s: string): Uint8Array {
|
|
121
|
+
const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice(0, (4 - (s.length % 4)) % 4)
|
|
122
|
+
const bin = atob(b64)
|
|
123
|
+
const out = new Uint8Array(bin.length)
|
|
124
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
|
|
125
|
+
return out
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A one-time random value (state / nonce / PKCE verifier alphabet). */
|
|
129
|
+
export function randomToken(): string {
|
|
130
|
+
return base64url(crypto.getRandomValues(new Uint8Array(24)))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface PkcePair { verifier: string; challenge: string }
|
|
134
|
+
|
|
135
|
+
/** PKCE (RFC 7636), S256 only — plain is never offered. */
|
|
136
|
+
export async function generatePkce(): Promise<PkcePair> {
|
|
137
|
+
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)))
|
|
138
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
|
|
139
|
+
return { verifier, challenge: base64url(new Uint8Array(digest)) }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── the authorization request ───────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
export function buildAuthorizationUrl(
|
|
145
|
+
metadata: OidcMetadata,
|
|
146
|
+
params: {
|
|
147
|
+
clientId: string
|
|
148
|
+
redirectUri: string
|
|
149
|
+
scopes: string
|
|
150
|
+
state: string
|
|
151
|
+
nonce: string
|
|
152
|
+
codeChallenge: string
|
|
153
|
+
},
|
|
154
|
+
): string {
|
|
155
|
+
const url = new URL(metadata.authorization_endpoint)
|
|
156
|
+
url.searchParams.set('response_type', 'code')
|
|
157
|
+
url.searchParams.set('client_id', params.clientId)
|
|
158
|
+
url.searchParams.set('redirect_uri', params.redirectUri)
|
|
159
|
+
url.searchParams.set('scope', params.scopes)
|
|
160
|
+
url.searchParams.set('state', params.state)
|
|
161
|
+
url.searchParams.set('nonce', params.nonce)
|
|
162
|
+
url.searchParams.set('code_challenge', params.codeChallenge)
|
|
163
|
+
url.searchParams.set('code_challenge_method', 'S256')
|
|
164
|
+
return url.toString()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── the code exchange ───────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
export interface OidcTokenResponse {
|
|
170
|
+
id_token: string
|
|
171
|
+
access_token?: string
|
|
172
|
+
token_type?: string
|
|
173
|
+
expires_in?: number
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function exchangeCode(
|
|
177
|
+
metadata: OidcMetadata,
|
|
178
|
+
params: {
|
|
179
|
+
clientId: string
|
|
180
|
+
clientSecret?: string
|
|
181
|
+
code: string
|
|
182
|
+
redirectUri: string
|
|
183
|
+
codeVerifier: string
|
|
184
|
+
},
|
|
185
|
+
fetchImpl: typeof fetch = fetch,
|
|
186
|
+
): Promise<OidcTokenResponse> {
|
|
187
|
+
const body = new URLSearchParams({
|
|
188
|
+
grant_type: 'authorization_code',
|
|
189
|
+
code: params.code,
|
|
190
|
+
redirect_uri: params.redirectUri,
|
|
191
|
+
client_id: params.clientId,
|
|
192
|
+
code_verifier: params.codeVerifier,
|
|
193
|
+
})
|
|
194
|
+
const headers: Record<string, string> = { 'content-type': 'application/x-www-form-urlencoded' }
|
|
195
|
+
if (params.clientSecret) {
|
|
196
|
+
headers.authorization = `Basic ${btoa(`${encodeURIComponent(params.clientId)}:${encodeURIComponent(params.clientSecret)}`)}`
|
|
197
|
+
}
|
|
198
|
+
let json: unknown
|
|
199
|
+
try {
|
|
200
|
+
const res = await fetchImpl(metadata.token_endpoint, { method: 'POST', headers, body })
|
|
201
|
+
json = await res.json()
|
|
202
|
+
if (!res.ok) {
|
|
203
|
+
const err = (json as { error?: string; error_description?: string }) ?? {}
|
|
204
|
+
throw new Error(`HTTP ${res.status} ${err.error ?? ''} ${err.error_description ?? ''}`.trim())
|
|
205
|
+
}
|
|
206
|
+
} catch (err) {
|
|
207
|
+
throw new OidcError('exchange', `the token endpoint refused the exchange: ${(err as Error).message}`)
|
|
208
|
+
}
|
|
209
|
+
const token = json as Partial<OidcTokenResponse>
|
|
210
|
+
if (typeof token?.id_token !== 'string') {
|
|
211
|
+
throw new OidcError('exchange', 'the token response carries no id_token — this flow requires the openid scope')
|
|
212
|
+
}
|
|
213
|
+
return token as OidcTokenResponse
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── ID-token validation ─────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
interface Jwk {
|
|
219
|
+
kty: string
|
|
220
|
+
kid?: string
|
|
221
|
+
alg?: string
|
|
222
|
+
use?: string
|
|
223
|
+
n?: string
|
|
224
|
+
e?: string
|
|
225
|
+
x?: string
|
|
226
|
+
y?: string
|
|
227
|
+
crv?: string
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
interface CachedJwks { keys: Jwk[]; fetchedAt: number }
|
|
231
|
+
const jwksCache = new Map<string, CachedJwks>()
|
|
232
|
+
const JWKS_TTL_MS = 60 * 60 * 1000
|
|
233
|
+
|
|
234
|
+
async function fetchJwks(jwksUri: string, fetchImpl: typeof fetch, force: boolean): Promise<Jwk[]> {
|
|
235
|
+
const cached = jwksCache.get(jwksUri)
|
|
236
|
+
if (!force && cached && Date.now() - cached.fetchedAt < JWKS_TTL_MS) return cached.keys
|
|
237
|
+
let body: unknown
|
|
238
|
+
try {
|
|
239
|
+
const res = await fetchImpl(jwksUri)
|
|
240
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
241
|
+
body = await res.json()
|
|
242
|
+
} catch (err) {
|
|
243
|
+
throw new OidcError('token_signature', `could not fetch the signing keys (${jwksUri}): ${(err as Error).message}`)
|
|
244
|
+
}
|
|
245
|
+
const keys = (body as { keys?: Jwk[] })?.keys
|
|
246
|
+
if (!Array.isArray(keys)) {
|
|
247
|
+
throw new OidcError('token_signature', `the JWKS at ${jwksUri} carries no keys array`)
|
|
248
|
+
}
|
|
249
|
+
jwksCache.set(jwksUri, { keys, fetchedAt: Date.now() })
|
|
250
|
+
return keys
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export interface OidcIdTokenClaims {
|
|
254
|
+
iss: string
|
|
255
|
+
sub: string
|
|
256
|
+
aud: string | string[]
|
|
257
|
+
exp: number
|
|
258
|
+
iat?: number
|
|
259
|
+
nonce?: string
|
|
260
|
+
azp?: string
|
|
261
|
+
email?: string
|
|
262
|
+
email_verified?: boolean
|
|
263
|
+
name?: string
|
|
264
|
+
[claim: string]: unknown
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const EXPIRY_LEEWAY_MS = 60_000
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Validate the ID token: signature against the IdP's JWKS, then the
|
|
271
|
+
* iss / aud / exp / nonce claims. Returns the claims on success, throws
|
|
272
|
+
* OidcError (a plain `reason`, never the raw crypto failure) otherwise.
|
|
273
|
+
*/
|
|
274
|
+
export async function validateIdToken(
|
|
275
|
+
idToken: string,
|
|
276
|
+
expectations: { issuer: string; clientId: string; nonce: string; jwksUri: string },
|
|
277
|
+
fetchImpl: typeof fetch = fetch,
|
|
278
|
+
): Promise<OidcIdTokenClaims> {
|
|
279
|
+
const parts = idToken.split('.')
|
|
280
|
+
if (parts.length !== 3) {
|
|
281
|
+
throw new OidcError('token_malformed', 'the ID token is not a three-part JWT')
|
|
282
|
+
}
|
|
283
|
+
let header: { alg?: string; kid?: string }
|
|
284
|
+
let claims: OidcIdTokenClaims
|
|
285
|
+
try {
|
|
286
|
+
header = JSON.parse(new TextDecoder().decode(base64urlDecode(parts[0]!)))
|
|
287
|
+
claims = JSON.parse(new TextDecoder().decode(base64urlDecode(parts[1]!))) as OidcIdTokenClaims
|
|
288
|
+
} catch {
|
|
289
|
+
throw new OidcError('token_malformed', 'the ID token header/claims are not JSON')
|
|
290
|
+
}
|
|
291
|
+
if (header.alg !== 'RS256' && header.alg !== 'ES256') {
|
|
292
|
+
throw new OidcError('token_alg', `the ID token uses ${header.alg ?? 'no declared algorithm'} — only RS256 and ES256 are verified`)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Signature: pick the JWKS key by kid (and algorithm family); a first
|
|
296
|
+
// miss refetches once (key rotation), then fails honestly.
|
|
297
|
+
const signedContent = new TextEncoder().encode(`${parts[0]}.${parts[1]}`)
|
|
298
|
+
const signature = base64urlDecode(parts[2]!)
|
|
299
|
+
let verified = false
|
|
300
|
+
for (const force of [false, true]) {
|
|
301
|
+
const keys = await fetchJwks(expectations.jwksUri, fetchImpl, force)
|
|
302
|
+
const candidates = keys.filter(k =>
|
|
303
|
+
(!header.kid || k.kid === header.kid)
|
|
304
|
+
&& (header.alg === 'RS256' ? k.kty === 'RSA' : k.kty === 'EC'),
|
|
305
|
+
)
|
|
306
|
+
for (const jwk of candidates) {
|
|
307
|
+
try {
|
|
308
|
+
const key = await crypto.subtle.importKey(
|
|
309
|
+
'jwk',
|
|
310
|
+
jwk as JsonWebKey,
|
|
311
|
+
header.alg === 'RS256'
|
|
312
|
+
? { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }
|
|
313
|
+
: { name: 'ECDSA', namedCurve: 'P-256' },
|
|
314
|
+
false,
|
|
315
|
+
['verify'],
|
|
316
|
+
)
|
|
317
|
+
verified = await crypto.subtle.verify(
|
|
318
|
+
header.alg === 'RS256'
|
|
319
|
+
? { name: 'RSASSA-PKCS1-v1_5' }
|
|
320
|
+
: { name: 'ECDSA', hash: 'SHA-256' },
|
|
321
|
+
key,
|
|
322
|
+
signature as BufferSource,
|
|
323
|
+
signedContent,
|
|
324
|
+
)
|
|
325
|
+
} catch {
|
|
326
|
+
verified = false // an unimportable key is a miss, never a pass
|
|
327
|
+
}
|
|
328
|
+
if (verified) break
|
|
329
|
+
}
|
|
330
|
+
if (verified) break
|
|
331
|
+
}
|
|
332
|
+
if (!verified) {
|
|
333
|
+
throw new OidcError('token_signature', 'the ID token signature does not verify against the issuer’s published keys')
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (claims.iss?.replace(/\/$/, '') !== expectations.issuer.replace(/\/$/, '')) {
|
|
337
|
+
throw new OidcError('token_issuer', `the ID token’s issuer (${claims.iss ?? 'none'}) is not the configured issuer`)
|
|
338
|
+
}
|
|
339
|
+
const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud]
|
|
340
|
+
if (!audiences.includes(expectations.clientId)) {
|
|
341
|
+
throw new OidcError('token_audience', 'the ID token was not issued for this application (audience mismatch)')
|
|
342
|
+
}
|
|
343
|
+
if (audiences.length > 1 && claims.azp && claims.azp !== expectations.clientId) {
|
|
344
|
+
throw new OidcError('token_audience', 'the ID token’s authorized party is not this application')
|
|
345
|
+
}
|
|
346
|
+
if (typeof claims.exp !== 'number' || claims.exp * 1000 + EXPIRY_LEEWAY_MS < Date.now()) {
|
|
347
|
+
throw new OidcError('token_expired', 'the ID token has expired')
|
|
348
|
+
}
|
|
349
|
+
if (claims.nonce !== expectations.nonce) {
|
|
350
|
+
throw new OidcError('token_nonce', 'the ID token’s nonce does not match the request (replay guard)')
|
|
351
|
+
}
|
|
352
|
+
return claims
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ── RP-initiated logout ─────────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
/** The IdP's end-session URL, or null when the metadata declares no
|
|
358
|
+
* end_session_endpoint (the local sign-out then stands alone). */
|
|
359
|
+
export function buildEndSessionUrl(
|
|
360
|
+
metadata: OidcMetadata,
|
|
361
|
+
params: { idTokenHint?: string | null; clientId: string; postLogoutRedirectUri: string },
|
|
362
|
+
): string | null {
|
|
363
|
+
if (!metadata.end_session_endpoint) return null
|
|
364
|
+
const url = new URL(metadata.end_session_endpoint)
|
|
365
|
+
if (params.idTokenHint) url.searchParams.set('id_token_hint', params.idTokenHint)
|
|
366
|
+
url.searchParams.set('client_id', params.clientId)
|
|
367
|
+
url.searchParams.set('post_logout_redirect_uri', params.postLogoutRedirectUri)
|
|
368
|
+
return url.toString()
|
|
369
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2
|
+
// The node half of the deployment-profile loader (TODO.federation/01):
|
|
3
|
+
// resolves the profile YAML from disk — the INSTANCE_PROFILE env path
|
|
4
|
+
// when declared, else <consumer root>/instance.profile.yaml when
|
|
5
|
+
// present, else the built-in hub default (the byte-identical-today
|
|
6
|
+
// posture) — parses it through the worker-safe core (./profile.ts),
|
|
7
|
+
// and installs it into the isolate slot the seed plans and /api/config
|
|
8
|
+
// read.
|
|
9
|
+
//
|
|
10
|
+
// The DEFAULT path anchors at the CONSUMER's root, never this
|
|
11
|
+
// package's own: the composition root passes its `root` (the smart
|
|
12
|
+
// monorepo's browser/server/profile-node.ts anchors it at the repo
|
|
13
|
+
// root from its own module location); an unrooted call falls back to
|
|
14
|
+
// the process's working directory. A kernel resolving its own
|
|
15
|
+
// import.meta.url here would look under packages/platform-server/
|
|
16
|
+
// (node_modules/@oimlsmart/platform-server/ once published) — a place
|
|
17
|
+
// no deployment profile ever lives, so a consumer's default file at
|
|
18
|
+
// its own root would be silently ignored (the wave-01 regression the
|
|
19
|
+
// root parameter fixes).
|
|
20
|
+
//
|
|
21
|
+
// NODE-ONLY (node:fs) — the Worker bundle never imports this module;
|
|
22
|
+
// the Worker's loader is profile.ts's resolveInstanceProfileFromEnv
|
|
23
|
+
// (the inline INSTANCE_PROFILE_YAML binding).
|
|
24
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
25
|
+
|
|
26
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
27
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
28
|
+
import {
|
|
29
|
+
installInstanceProfile,
|
|
30
|
+
parseInstanceProfile,
|
|
31
|
+
type InstanceProfile,
|
|
32
|
+
} from '../profile'
|
|
33
|
+
|
|
34
|
+
export interface LoadNodeProfileOptions {
|
|
35
|
+
/** Override the file resolution (tests); still installs + memoizes. */
|
|
36
|
+
path?: string
|
|
37
|
+
/** The consumer's root for the default profile path
|
|
38
|
+
* (<root>/instance.profile.yaml) — the composition root's context.
|
|
39
|
+
* Defaults to the process's working directory. NEVER this package's
|
|
40
|
+
* own root: the kernel ships no default profile and never reads one
|
|
41
|
+
* from its own tree. */
|
|
42
|
+
root?: string
|
|
43
|
+
/** Re-read even when a profile is already loaded (tests). */
|
|
44
|
+
force?: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let loaded: InstanceProfile | null = null
|
|
48
|
+
|
|
49
|
+
/** The profile file this process boots from: $INSTANCE_PROFILE (relative
|
|
50
|
+
* paths resolve against the CWD — the `browser/` dir under npm run dev)
|
|
51
|
+
* when declared, else <root>/instance.profile.yaml when it exists
|
|
52
|
+
* (the consumer's root, defaulting to the CWD), else no file (the hub
|
|
53
|
+
* default). A DECLARED-but-missing file fails the boot — a misdeclared
|
|
54
|
+
* deployment must never silently boot as the hub. */
|
|
55
|
+
export function resolveNodeProfilePath(env: NodeJS.ProcessEnv = process.env, root: string = process.cwd()): string | null {
|
|
56
|
+
const declared = env.INSTANCE_PROFILE
|
|
57
|
+
if (declared) {
|
|
58
|
+
const path = isAbsolute(declared) ? declared : resolve(process.cwd(), declared)
|
|
59
|
+
if (!existsSync(path)) {
|
|
60
|
+
throw new Error(`instance profile: INSTANCE_PROFILE points at ${path}, which does not exist`)
|
|
61
|
+
}
|
|
62
|
+
return path
|
|
63
|
+
}
|
|
64
|
+
const defaultPath = join(root, 'instance.profile.yaml')
|
|
65
|
+
return existsSync(defaultPath) ? defaultPath : null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Load, install, and return the effective profile (memoized per
|
|
69
|
+
* process). The SEED_DEMO_PERSONAS env flag forces the demo cast on
|
|
70
|
+
* for the ia/tl profiles (never off — the file's `demo_personas:
|
|
71
|
+
* false` still holds under the flag's absence). */
|
|
72
|
+
export function loadNodeInstanceProfile(options?: LoadNodeProfileOptions): InstanceProfile {
|
|
73
|
+
if (loaded && !options?.force) return loaded
|
|
74
|
+
const path = options?.path ?? resolveNodeProfilePath(process.env, options?.root)
|
|
75
|
+
const text = path === null ? undefined : readFileSync(path, 'utf-8')
|
|
76
|
+
const demoEnv = process.env.SEED_DEMO_PERSONAS
|
|
77
|
+
const profile = parseInstanceProfile(text, {
|
|
78
|
+
demoPersonas: demoEnv === 'true' || demoEnv === '1' ? true : undefined,
|
|
79
|
+
})
|
|
80
|
+
installInstanceProfile(profile)
|
|
81
|
+
loaded = profile
|
|
82
|
+
return profile
|
|
83
|
+
}
|