@astrale-os/cli 1.0.0-beta.31 → 1.0.0-beta.32
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/dist/astrale.js +128 -43
- package/dist/public/connect-core.js +42 -13
- package/dist/public/keys/index.js +14 -0
- package/dist/public/paths/index.js +14 -0
- package/dist/types/connection/auth.d.ts +1 -0
- package/dist/types/connection/credential.d.ts +1 -1
- package/dist/types/connection/lifetime.d.ts +6 -0
- package/dist/types/lib/credential-lifetime.d.ts +3 -0
- package/dist/types/lib/idp-session.d.ts +2 -0
- package/dist/types/lib/idp.d.ts +1 -1
- package/dist/types/state/exchange-credentials.d.ts +1 -1
- package/package.json +1 -1
- package/src/connection/.spec/architecture.md +5 -2
- package/src/connection/.spec/flows/session.ts +2 -1
- package/src/connection/.spec/laws/connection.ts +9 -1
- package/src/connection/.spec/layout.ts +1 -0
- package/src/connection/__tests__/credential.test.ts +115 -0
- package/src/connection/__tests__/exchange.test.ts +94 -9
- package/src/connection/auth.ts +20 -4
- package/src/connection/credential.ts +29 -8
- package/src/connection/exchange.ts +65 -7
- package/src/connection/lifetime.ts +25 -0
- package/src/lib/__tests__/idp-session.driver.ts +10 -3
- package/src/lib/__tests__/idp-session.test.ts +25 -0
- package/src/lib/credential-lifetime.ts +24 -0
- package/src/lib/idp-session.ts +26 -4
- package/src/lib/idp.ts +24 -4
- package/src/state/__tests__/exchange-credentials.test.ts +112 -5
- package/src/state/exchange-credentials.ts +26 -8
|
@@ -31,8 +31,11 @@ current Grant for routing. Connection itself persists no credential or route; th
|
|
|
31
31
|
owner persists exchanged source credentials and the separate Kernel Client route artifact. A valid exchanged credential is selected by the
|
|
32
32
|
authenticated source issuer and subject before any live `whoami`; cache misses alone resolve the
|
|
33
33
|
registered Kernel User and perform delegation plus Domain exchange.
|
|
34
|
-
Exchange
|
|
35
|
-
|
|
34
|
+
Exchange and destination-carrier authority cover the selected command timeout plus one bounded
|
|
35
|
+
receipt margin, never outlive the current source credential, and retain the existing one-minute
|
|
36
|
+
floor for short commands. A cached or freshly exchanged credential that cannot cover that lifetime
|
|
37
|
+
is refreshed or rejected before destination dispatch, so a long durable mutation does not first
|
|
38
|
+
discover expired callback authority after its provider effect commits.
|
|
36
39
|
|
|
37
40
|
Every ClientSession receives the CLI-owned `state/session-routes` representation capability. Kernel
|
|
38
41
|
Client still owns route keying, admission, expiry, and one safe stale/miss recovery; Connection does
|
|
@@ -24,8 +24,9 @@ declare const resolveSourceCredential: (
|
|
|
24
24
|
) => Promise<string>
|
|
25
25
|
/** Bind source authority without learning or minting destination credentials. */
|
|
26
26
|
function createConnectionAuth(target: ConnectionTarget, options: ConnectionOptions): SessionAuth {
|
|
27
|
+
const ttlSeconds = Math.max(60, Math.ceil(resolveTimeoutMs(options.timeout) / 1_000) + 5)
|
|
27
28
|
return {
|
|
28
|
-
ttlSeconds
|
|
29
|
+
ttlSeconds,
|
|
29
30
|
async resolve(_call: Call, signal: AbortSignal) {
|
|
30
31
|
return {
|
|
31
32
|
credential: await resolveSourceCredential(target, options, target.issuer, signal),
|
|
@@ -135,12 +135,20 @@ export const CLI_CONNECTION_TERMINAL_CLOSE = defineLaw({
|
|
|
135
135
|
export const CLI_CONNECTION_TIMEOUT = defineLaw({
|
|
136
136
|
id: 'CLI-CONNECTION-TIMEOUT',
|
|
137
137
|
statement:
|
|
138
|
-
'The CLI accepts only a positive integer timeout before constructing a Client Session
|
|
138
|
+
'The CLI accepts only a positive integer timeout before constructing a Client Session, applies it to source-Auth and Session operations, and requires exchanged and destination-carrier authority to cover that timeout plus the bounded receipt margin before destination dispatch.',
|
|
139
139
|
tests: [
|
|
140
140
|
{
|
|
141
141
|
file: '__tests__/session.test.ts',
|
|
142
142
|
id: 'TEST-CLI-CONNECTION-REJECTS-INVALID-TIMEOUT-BEFORE-OPEN',
|
|
143
143
|
},
|
|
144
|
+
{
|
|
145
|
+
file: '__tests__/credential.test.ts',
|
|
146
|
+
id: 'TEST-CLI-CONNECTION-CARRIER-COVERS-COMMAND-TIMEOUT',
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
file: '__tests__/exchange.test.ts',
|
|
150
|
+
id: 'TEST-CLI-EXCHANGE-REJECTS-INSUFFICIENT-LIFETIME',
|
|
151
|
+
},
|
|
144
152
|
],
|
|
145
153
|
})
|
|
146
154
|
|
|
@@ -3,9 +3,13 @@ import type { SessionAuth } from '@astrale-os/sdk/client/session'
|
|
|
3
3
|
import { issuer, type IssuerId } from '@astrale-os/sdk/auth'
|
|
4
4
|
import { Path } from '@astrale-os/sdk/graph/path'
|
|
5
5
|
import { describe, expect, test } from 'bun:test'
|
|
6
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
7
|
+
import { tmpdir } from 'node:os'
|
|
8
|
+
import { join } from 'node:path'
|
|
6
9
|
|
|
7
10
|
import type { AstraleConfig } from '../../lib/config'
|
|
8
11
|
|
|
12
|
+
import { persistKeypair, signAs } from '../../keys/index'
|
|
9
13
|
import { bindCredentialIdentity } from '../auth'
|
|
10
14
|
import { createCliCredential, createConnectionCredential } from '../credential'
|
|
11
15
|
|
|
@@ -77,6 +81,117 @@ describe('connection credential', () => {
|
|
|
77
81
|
expect(resolved.delegate?.ttlSeconds).toBeLessThan(120)
|
|
78
82
|
})
|
|
79
83
|
|
|
84
|
+
/** @evidence TEST-CLI-CONNECTION-CARRIER-COVERS-COMMAND-TIMEOUT */
|
|
85
|
+
test('covers a long command deadline with one destination carrier', async () => {
|
|
86
|
+
const expiresAt = Math.ceil(Date.now() / 1_000) + 300
|
|
87
|
+
const auth = createConnectionCredential(SOURCE, { resolve: async () => token(expiresAt) }, 185)
|
|
88
|
+
|
|
89
|
+
await expect(auth.resolve(TARGET_CALL, new AbortController().signal)).resolves.toMatchObject({
|
|
90
|
+
credential: token(expiresAt),
|
|
91
|
+
delegate: { ttlSeconds: 185 },
|
|
92
|
+
})
|
|
93
|
+
expect(auth.ttlSeconds).toBe(185)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('real local-key credentials cover the supported long-operation carrier', async () => {
|
|
97
|
+
const directory = await mkdtemp(join(tmpdir(), 'astrale-carrier-key-'))
|
|
98
|
+
try {
|
|
99
|
+
await persistKeypair('alice', { keysDir: directory })
|
|
100
|
+
const source = await signAs('alice', directory, {
|
|
101
|
+
issuer: SOURCE,
|
|
102
|
+
audience: SOURCE,
|
|
103
|
+
})
|
|
104
|
+
const auth = createConnectionCredential(SOURCE, { resolve: async () => source }, 185)
|
|
105
|
+
|
|
106
|
+
await expect(auth.resolve(TARGET_CALL, new AbortController().signal)).resolves.toMatchObject({
|
|
107
|
+
credential: source,
|
|
108
|
+
delegate: { ttlSeconds: 185 },
|
|
109
|
+
})
|
|
110
|
+
} finally {
|
|
111
|
+
await rm(directory, { recursive: true, force: true })
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('real five-minute local-key credentials reject a 600-second carrier before dispatch', async () => {
|
|
116
|
+
const directory = await mkdtemp(join(tmpdir(), 'astrale-carrier-ceiling-key-'))
|
|
117
|
+
try {
|
|
118
|
+
await persistKeypair('alice', { keysDir: directory })
|
|
119
|
+
const source = await signAs('alice', directory, {
|
|
120
|
+
issuer: SOURCE,
|
|
121
|
+
audience: SOURCE,
|
|
122
|
+
})
|
|
123
|
+
const auth = createConnectionCredential(SOURCE, { resolve: async () => source }, 605)
|
|
124
|
+
|
|
125
|
+
await expect(auth.resolve(TARGET_CALL, new AbortController().signal)).rejects.toMatchObject({
|
|
126
|
+
code: 'CREDENTIAL_LIFETIME_INSUFFICIENT',
|
|
127
|
+
})
|
|
128
|
+
} finally {
|
|
129
|
+
await rm(directory, { recursive: true, force: true })
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('derives every destination carrier lifetime from the selected CLI timeout', () => {
|
|
134
|
+
for (const target of [
|
|
135
|
+
{ url: `${SOURCE}/invoke`, kernelIssuer: SOURCE },
|
|
136
|
+
{
|
|
137
|
+
url: `${SOURCE}/invoke`,
|
|
138
|
+
kernelIssuer: SOURCE,
|
|
139
|
+
domainIssuer: issuer.accept('https://admin.example'),
|
|
140
|
+
},
|
|
141
|
+
]) {
|
|
142
|
+
const auth = createCliCredential(target, {}, config, globalThis.fetch, 180_000)
|
|
143
|
+
expect(auth?.ttlSeconds).toBe(185)
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('rejects a long command before dispatch when its source bearer is too short', async () => {
|
|
148
|
+
const expiresAt = Math.ceil(Date.now() / 1_000) + 120
|
|
149
|
+
const auth = createConnectionCredential(SOURCE, { resolve: async () => token(expiresAt) }, 185)
|
|
150
|
+
|
|
151
|
+
await expect(auth.resolve(TARGET_CALL, new AbortController().signal)).rejects.toMatchObject({
|
|
152
|
+
code: 'CREDENTIAL_LIFETIME_INSUFFICIENT',
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('rejects an inspectable short or expired bearer before a default command dispatch', async () => {
|
|
157
|
+
for (const expiresAt of [
|
|
158
|
+
Math.ceil(Date.now() / 1_000) + 30,
|
|
159
|
+
Math.ceil(Date.now() / 1_000) - 30,
|
|
160
|
+
]) {
|
|
161
|
+
const auth = createConnectionCredential(SOURCE, {
|
|
162
|
+
resolve: async () => token(expiresAt),
|
|
163
|
+
})
|
|
164
|
+
await expect(auth.resolve(TARGET_CALL, new AbortController().signal)).rejects.toMatchObject({
|
|
165
|
+
code: 'CREDENTIAL_LIFETIME_INSUFFICIENT',
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('rejects a too-short explicit Domain bearer before exchange or destination I/O', async () => {
|
|
171
|
+
let fetches = 0
|
|
172
|
+
const expiresAt = Math.ceil(Date.now() / 1_000) + 120
|
|
173
|
+
const auth = createCliCredential(
|
|
174
|
+
{
|
|
175
|
+
url: `${SOURCE}/invoke`,
|
|
176
|
+
kernelIssuer: SOURCE,
|
|
177
|
+
domainIssuer: issuer.accept('https://admin.example'),
|
|
178
|
+
},
|
|
179
|
+
{ creds: token(expiresAt) },
|
|
180
|
+
config,
|
|
181
|
+
async () => {
|
|
182
|
+
fetches += 1
|
|
183
|
+
throw new Error('network must remain untouched')
|
|
184
|
+
},
|
|
185
|
+
180_000,
|
|
186
|
+
)
|
|
187
|
+
if (auth === undefined) throw new Error('expected authenticated credential')
|
|
188
|
+
|
|
189
|
+
await expect(auth.resolve(TARGET_CALL, new AbortController().signal)).rejects.toMatchObject({
|
|
190
|
+
code: 'CREDENTIAL_LIFETIME_INSUFFICIENT',
|
|
191
|
+
})
|
|
192
|
+
expect(fetches).toBe(0)
|
|
193
|
+
})
|
|
194
|
+
|
|
80
195
|
/** @evidence TEST-CLI-CONNECTION-USES-RAW-SOURCE-CREDENTIAL */
|
|
81
196
|
test('binds explicit CLI credentials to source-Kernel auth only', async () => {
|
|
82
197
|
const auth = createCliCredential(
|
|
@@ -101,9 +101,7 @@ describe('Domain token exchange', () => {
|
|
|
101
101
|
},
|
|
102
102
|
})
|
|
103
103
|
const delegatedTtl = kernelRequests[1]!.body!.call.input.ttlSeconds
|
|
104
|
-
expect(delegatedTtl).
|
|
105
|
-
expect(delegatedTtl).toBeGreaterThanOrEqual(295)
|
|
106
|
-
expect(delegatedTtl).toBeLessThanOrEqual(300)
|
|
104
|
+
expect(delegatedTtl).toBe(75)
|
|
107
105
|
expect(sourceAudiences).toEqual([KERNEL, KERNEL])
|
|
108
106
|
expect(
|
|
109
107
|
observed.filter((entry) => entry.url.endsWith('/.well-known/astrale/token')),
|
|
@@ -173,6 +171,87 @@ describe('Domain token exchange', () => {
|
|
|
173
171
|
})
|
|
174
172
|
})
|
|
175
173
|
|
|
174
|
+
test('requests and retains a carrier that covers a supported long command deadline', async () => {
|
|
175
|
+
const expiresAt = Math.floor(Date.now() / 1_000) + 250
|
|
176
|
+
const exchanged = token(DOMAIN, KERNEL, 'user-1', expiresAt)
|
|
177
|
+
const observed: Record<string, any>[] = []
|
|
178
|
+
const base = exchangeFetch(exchanged, { expiresAt })
|
|
179
|
+
const fetch: Fetch = async (input, init) => {
|
|
180
|
+
if (String(input) === INVOCATION) {
|
|
181
|
+
observed.push(JSON.parse(await new Response(init?.body).text()))
|
|
182
|
+
}
|
|
183
|
+
return base(input, init)
|
|
184
|
+
}
|
|
185
|
+
const resolver = createExchangeCredentialResolver(
|
|
186
|
+
TARGET,
|
|
187
|
+
{ resolve: async () => sourceToken('user-1', expiresAt + 50) },
|
|
188
|
+
fetch,
|
|
189
|
+
180_000,
|
|
190
|
+
new ExchangeCredentialCache(join(directory, 'long-credential.json')),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
await expect(resolver.resolve(KERNEL, new AbortController().signal)).resolves.toBe(exchanged)
|
|
194
|
+
expect(observed[1]!.call.input.ttlSeconds).toBe(200)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
/** @evidence TEST-CLI-EXCHANGE-REJECTS-INSUFFICIENT-LIFETIME */
|
|
198
|
+
test('rejects a too-short Domain exchange credential before destination dispatch', async () => {
|
|
199
|
+
const expiresAt = Math.floor(Date.now() / 1_000) + 150
|
|
200
|
+
const exchanged = token(DOMAIN, KERNEL, 'user-1', expiresAt)
|
|
201
|
+
const resolver = createExchangeCredentialResolver(
|
|
202
|
+
TARGET,
|
|
203
|
+
{ resolve: async () => sourceToken('user-1', expiresAt + 150) },
|
|
204
|
+
exchangeFetch(exchanged, { expiresAt }),
|
|
205
|
+
180_000,
|
|
206
|
+
new ExchangeCredentialCache(join(directory, 'short-credential.json')),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
await expect(resolver.resolve(KERNEL, new AbortController().signal)).rejects.toMatchObject({
|
|
210
|
+
code: 'TOKEN_EXCHANGE_LIFETIME_INSUFFICIENT',
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test('rejects a long outer Domain token whose carried Kernel proof is too short', async () => {
|
|
215
|
+
const outerExpiresAt = Math.floor(Date.now() / 1_000) + 250
|
|
216
|
+
const proofExpiresAt = Math.floor(Date.now() / 1_000) + 150
|
|
217
|
+
const exchanged = token(DOMAIN, KERNEL, 'user-1', outerExpiresAt, proofExpiresAt)
|
|
218
|
+
const resolver = createExchangeCredentialResolver(
|
|
219
|
+
TARGET,
|
|
220
|
+
{ resolve: async () => sourceToken('user-1', outerExpiresAt + 50) },
|
|
221
|
+
exchangeFetch(exchanged, { expiresAt: outerExpiresAt }),
|
|
222
|
+
180_000,
|
|
223
|
+
new ExchangeCredentialCache(join(directory, 'short-proof.json')),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
await expect(resolver.resolve(KERNEL, new AbortController().signal)).rejects.toMatchObject({
|
|
227
|
+
code: 'TOKEN_EXCHANGE_LIFETIME_INSUFFICIENT',
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('rejects the real 300-second Domain ceiling for a 600-second command before destination dispatch', async () => {
|
|
232
|
+
const expiresAt = Math.floor(Date.now() / 1_000) + 300
|
|
233
|
+
const exchanged = token(DOMAIN, KERNEL, 'user-1', expiresAt)
|
|
234
|
+
const observed: string[] = []
|
|
235
|
+
const base = exchangeFetch(exchanged, { expiresAt })
|
|
236
|
+
const fetch: Fetch = async (input, init) => {
|
|
237
|
+
observed.push(String(input))
|
|
238
|
+
return base(input, init)
|
|
239
|
+
}
|
|
240
|
+
const resolver = createExchangeCredentialResolver(
|
|
241
|
+
TARGET,
|
|
242
|
+
{ resolve: async () => sourceToken('user-1', expiresAt + 700) },
|
|
243
|
+
fetch,
|
|
244
|
+
600_000,
|
|
245
|
+
new ExchangeCredentialCache(join(directory, 'domain-ceiling.json')),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
await expect(resolver.resolve(KERNEL, new AbortController().signal)).rejects.toMatchObject({
|
|
249
|
+
code: 'TOKEN_EXCHANGE_LIFETIME_INSUFFICIENT',
|
|
250
|
+
})
|
|
251
|
+
expect(observed.filter((url) => url === INVOCATION)).toHaveLength(2)
|
|
252
|
+
expect(observed.filter((url) => url.endsWith('/.well-known/astrale/token'))).toHaveLength(1)
|
|
253
|
+
})
|
|
254
|
+
|
|
176
255
|
test('rejects success responses without no-store or with malformed fields', async () => {
|
|
177
256
|
const exchanged = token(DOMAIN, KERNEL, 'user-1', EXPIRES_AT)
|
|
178
257
|
const cache = () => new ExchangeCredentialCache(join(directory, crypto.randomUUID()))
|
|
@@ -237,7 +316,11 @@ describe('Domain token exchange', () => {
|
|
|
237
316
|
|
|
238
317
|
function exchangeFetch(
|
|
239
318
|
exchanged: string,
|
|
240
|
-
options: {
|
|
319
|
+
options: {
|
|
320
|
+
readonly body?: unknown
|
|
321
|
+
readonly cacheControl?: boolean
|
|
322
|
+
readonly expiresAt?: number
|
|
323
|
+
} = {},
|
|
241
324
|
): Fetch {
|
|
242
325
|
return async (input, init) => {
|
|
243
326
|
const url = String(input)
|
|
@@ -253,7 +336,9 @@ function exchangeFetch(
|
|
|
253
336
|
}
|
|
254
337
|
if (url.endsWith('/.well-known/openid-configuration')) return jsonResponse(configuration(true))
|
|
255
338
|
const response = new Response(
|
|
256
|
-
JSON.stringify(
|
|
339
|
+
JSON.stringify(
|
|
340
|
+
options.body ?? { token: exchanged, expiresAt: options.expiresAt ?? EXPIRES_AT },
|
|
341
|
+
),
|
|
257
342
|
{
|
|
258
343
|
status: 200,
|
|
259
344
|
headers: {
|
|
@@ -290,13 +375,13 @@ function jsonResponse(value: unknown, status = 200, contentType = 'application/j
|
|
|
290
375
|
})
|
|
291
376
|
}
|
|
292
377
|
|
|
293
|
-
function token(iss: string, aud: string, user: string, exp: number): string {
|
|
378
|
+
function token(iss: string, aud: string, user: string, exp: number, proofExp = exp): string {
|
|
294
379
|
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url')
|
|
295
380
|
const proof = `${encode({ alg: 'EdDSA', typ: 'JWT' })}.${encode({
|
|
296
381
|
iss: aud,
|
|
297
382
|
sub: user,
|
|
298
383
|
aud,
|
|
299
|
-
exp,
|
|
384
|
+
exp: proofExp,
|
|
300
385
|
delegation: { v: 1, expr: { kind: 'identity', id: user } },
|
|
301
386
|
})}.signature`
|
|
302
387
|
return `${encode({ alg: 'EdDSA', typ: 'JWT' })}.${encode({
|
|
@@ -308,12 +393,12 @@ function token(iss: string, aud: string, user: string, exp: number): string {
|
|
|
308
393
|
})}.signature`
|
|
309
394
|
}
|
|
310
395
|
|
|
311
|
-
function sourceToken(subject: string | undefined): string {
|
|
396
|
+
function sourceToken(subject: string | undefined, expiresAt = SOURCE_EXPIRES_AT): string {
|
|
312
397
|
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url')
|
|
313
398
|
return `${encode({ alg: 'EdDSA', typ: 'JWT' })}.${encode({
|
|
314
399
|
iss: 'https://workos.example',
|
|
315
400
|
...(subject === undefined ? {} : { sub: subject }),
|
|
316
401
|
aud: KERNEL,
|
|
317
|
-
exp:
|
|
402
|
+
exp: expiresAt,
|
|
318
403
|
})}.signature`
|
|
319
404
|
}
|
package/src/connection/auth.ts
CHANGED
|
@@ -47,7 +47,12 @@ export async function bindCredentialIdentity<Options extends ConnectionOptions>(
|
|
|
47
47
|
* registration for the target instance, use that target-issued `(iss, sub)`.
|
|
48
48
|
*/
|
|
49
49
|
export async function resolveCredential(
|
|
50
|
-
opts: {
|
|
50
|
+
opts: {
|
|
51
|
+
as?: string
|
|
52
|
+
creds?: string
|
|
53
|
+
defaultIdentity?: string
|
|
54
|
+
minimumRemainingSeconds?: number
|
|
55
|
+
},
|
|
51
56
|
config: AstraleConfig,
|
|
52
57
|
audience: string = config.issuer,
|
|
53
58
|
registrationKey?: string,
|
|
@@ -69,7 +74,12 @@ export async function resolveCredential(
|
|
|
69
74
|
resolvedIdentity = identity
|
|
70
75
|
resolvedName = identityName
|
|
71
76
|
if ((identity.source ?? 'key') === 'idp')
|
|
72
|
-
return await resolveIdpAccessToken(
|
|
77
|
+
return await resolveIdpAccessToken(
|
|
78
|
+
identityName,
|
|
79
|
+
identity,
|
|
80
|
+
audience,
|
|
81
|
+
opts.minimumRemainingSeconds,
|
|
82
|
+
)
|
|
73
83
|
return await signAs(
|
|
74
84
|
identity.subject,
|
|
75
85
|
KEYS_DIR,
|
|
@@ -81,7 +91,12 @@ export async function resolveCredential(
|
|
|
81
91
|
resolvedIdentity = identity
|
|
82
92
|
resolvedName = identity.name
|
|
83
93
|
if ((identity.source ?? 'key') === 'idp') {
|
|
84
|
-
return await resolveIdpAccessToken(
|
|
94
|
+
return await resolveIdpAccessToken(
|
|
95
|
+
identity.name,
|
|
96
|
+
identity,
|
|
97
|
+
audience,
|
|
98
|
+
opts.minimumRemainingSeconds,
|
|
99
|
+
)
|
|
85
100
|
}
|
|
86
101
|
|
|
87
102
|
return await signAs(
|
|
@@ -164,10 +179,11 @@ async function resolveIdpAccessToken(
|
|
|
164
179
|
identityName: string,
|
|
165
180
|
identity: Identity,
|
|
166
181
|
audience: string,
|
|
182
|
+
minimumRemainingSeconds?: number,
|
|
167
183
|
): Promise<string> {
|
|
168
184
|
let resolved
|
|
169
185
|
try {
|
|
170
|
-
resolved = await ensureFreshSession(identityName, { audience })
|
|
186
|
+
resolved = await ensureFreshSession(identityName, { audience, minimumRemainingSeconds })
|
|
171
187
|
} catch (e) {
|
|
172
188
|
if (e instanceof IdpSessionMissingError) {
|
|
173
189
|
throw new Error(
|
|
@@ -7,8 +7,10 @@ import type { AstraleConfig } from '../lib/config'
|
|
|
7
7
|
import type { ConnectionOptions, ConnectionTarget } from './target'
|
|
8
8
|
|
|
9
9
|
import { AstraleError } from '../errors'
|
|
10
|
+
import { remainingCredentialLifetimeSeconds } from '../lib/credential-lifetime'
|
|
10
11
|
import { resolveCredential } from './auth'
|
|
11
12
|
import { createExchangeCredentialResolver } from './exchange'
|
|
13
|
+
import { exchangeCredentialTtlSeconds, invocationCredentialTtlSeconds } from './lifetime'
|
|
12
14
|
import { registrationKeyForTarget } from './target'
|
|
13
15
|
|
|
14
16
|
const DELEGATION_TTL_SECONDS = 60
|
|
@@ -21,32 +23,46 @@ export interface SourceCredentialResolver {
|
|
|
21
23
|
export function createConnectionCredential(
|
|
22
24
|
expectedSourceIssuer: IssuerId,
|
|
23
25
|
source: SourceCredentialResolver,
|
|
26
|
+
ttlSeconds = DELEGATION_TTL_SECONDS,
|
|
24
27
|
): SessionAuth {
|
|
28
|
+
if (!Number.isSafeInteger(ttlSeconds) || ttlSeconds < 1) {
|
|
29
|
+
throw new TypeError('Connection credential ttlSeconds must be a positive safe integer.')
|
|
30
|
+
}
|
|
25
31
|
const resolveSource = source.resolve.bind(source)
|
|
26
32
|
return Object.freeze({
|
|
27
|
-
ttlSeconds
|
|
33
|
+
ttlSeconds,
|
|
28
34
|
async resolve(
|
|
29
35
|
_call: Parameters<SessionAuth['resolve']>[0],
|
|
30
36
|
signal: Parameters<SessionAuth['resolve']>[1],
|
|
31
37
|
) {
|
|
32
38
|
const resolved = await resolveSource(expectedSourceIssuer, signal)
|
|
33
|
-
const
|
|
39
|
+
const delegatedTtlSeconds = sourceBoundDelegationTtl(resolved, ttlSeconds)
|
|
34
40
|
return Object.freeze({
|
|
35
41
|
credential: resolved,
|
|
36
|
-
...(
|
|
42
|
+
...(delegatedTtlSeconds === undefined
|
|
43
|
+
? {}
|
|
44
|
+
: { delegate: { ttlSeconds: delegatedTtlSeconds } }),
|
|
37
45
|
})
|
|
38
46
|
},
|
|
39
47
|
})
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
/** Never request a destination carrier that could outlive its current source bearer. */
|
|
43
|
-
function sourceBoundDelegationTtl(input: string): number | undefined {
|
|
51
|
+
function sourceBoundDelegationTtl(input: string, requestedTtlSeconds: number): number | undefined {
|
|
44
52
|
try {
|
|
45
53
|
const expiresAt = credential.inspect(input).claims.exp
|
|
46
54
|
if (typeof expiresAt !== 'number' || !Number.isSafeInteger(expiresAt)) return undefined
|
|
47
|
-
const remaining = expiresAt
|
|
48
|
-
|
|
49
|
-
|
|
55
|
+
const remaining = remainingCredentialLifetimeSeconds(expiresAt)
|
|
56
|
+
if (remaining < requestedTtlSeconds) {
|
|
57
|
+
throw new AstraleError(
|
|
58
|
+
'CREDENTIAL_LIFETIME_INSUFFICIENT',
|
|
59
|
+
'The selected credential cannot cover the requested command timeout.',
|
|
60
|
+
`Use a fresh identity session or a shorter --timeout; ${Math.max(0, remaining)} seconds remain but ${requestedTtlSeconds} are required.`,
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
return Math.max(1, Math.min(requestedTtlSeconds, remaining))
|
|
64
|
+
} catch (cause) {
|
|
65
|
+
if (cause instanceof AstraleError) throw cause
|
|
50
66
|
// Preserve opaque explicit credentials; the Kernel remains their authority.
|
|
51
67
|
return undefined
|
|
52
68
|
}
|
|
@@ -66,6 +82,10 @@ export function createCliCredential(
|
|
|
66
82
|
...(options.as === undefined ? {} : { as: options.as }),
|
|
67
83
|
...(options.creds === undefined ? {} : { creds: options.creds }),
|
|
68
84
|
...(target.defaultIdentity === undefined ? {} : { defaultIdentity: target.defaultIdentity }),
|
|
85
|
+
minimumRemainingSeconds:
|
|
86
|
+
target.domainIssuer === undefined
|
|
87
|
+
? invocationCredentialTtlSeconds(timeoutMs)
|
|
88
|
+
: exchangeCredentialTtlSeconds(timeoutMs),
|
|
69
89
|
})
|
|
70
90
|
const source: SourceCredentialResolver = {
|
|
71
91
|
async resolve(audience, signal) {
|
|
@@ -89,7 +109,8 @@ export function createCliCredential(
|
|
|
89
109
|
fetch,
|
|
90
110
|
timeoutMs,
|
|
91
111
|
)
|
|
92
|
-
|
|
112
|
+
const ttlSeconds = invocationCredentialTtlSeconds(timeoutMs)
|
|
113
|
+
return createConnectionCredential(target.kernelIssuer, effective, ttlSeconds)
|
|
93
114
|
}
|
|
94
115
|
|
|
95
116
|
/** Reject contradictory explicit credential selections before identity or network access. */
|
|
@@ -2,16 +2,17 @@ import type { IssuerId } from '@astrale-os/sdk/auth'
|
|
|
2
2
|
import type { Fetch } from '@astrale-os/sdk/client'
|
|
3
3
|
|
|
4
4
|
import { createAuth } from '@astrale-os/sdk/auth'
|
|
5
|
-
import { credential, exchange as exchangeProtocol } from '@astrale-os/sdk/auth'
|
|
5
|
+
import { credential, exchange as exchangeProtocol, grant } from '@astrale-os/sdk/auth'
|
|
6
6
|
import { call, Client } from '@astrale-os/sdk/client'
|
|
7
7
|
|
|
8
8
|
import type { SourceCredentialResolver } from './credential'
|
|
9
9
|
import type { ConnectionTarget } from './target'
|
|
10
10
|
|
|
11
11
|
import { AstraleError } from '../errors'
|
|
12
|
+
import { remainingCredentialLifetimeSeconds } from '../lib/credential-lifetime'
|
|
12
13
|
import { ExchangeCredentialCache } from '../state/exchange-credentials'
|
|
14
|
+
import { cachedCredentialTtlSeconds, exchangeCredentialTtlSeconds } from './lifetime'
|
|
13
15
|
|
|
14
|
-
const EXCHANGE_TTL_SECONDS = 5 * 60
|
|
15
16
|
const MAXIMUM_RESPONSE_BYTES = 256 * 1024
|
|
16
17
|
|
|
17
18
|
/** Exchange exact authenticated User authority for a Domain bearer bound to this Kernel. */
|
|
@@ -23,6 +24,8 @@ export function createExchangeCredentialResolver(
|
|
|
23
24
|
cache = new ExchangeCredentialCache(),
|
|
24
25
|
): SourceCredentialResolver {
|
|
25
26
|
requireExchangeTransport(target)
|
|
27
|
+
const cacheTtlSeconds = cachedCredentialTtlSeconds(timeoutMs)
|
|
28
|
+
const exchangeTtlSeconds = exchangeCredentialTtlSeconds(timeoutMs)
|
|
26
29
|
return Object.freeze({
|
|
27
30
|
async resolve(kernelIssuer: IssuerId, signal: AbortSignal): Promise<string> {
|
|
28
31
|
requireLive(signal)
|
|
@@ -37,8 +40,9 @@ export function createExchangeCredentialResolver(
|
|
|
37
40
|
sourceIssuer: sourceIdentity.issuer,
|
|
38
41
|
sourceSubject: sourceIdentity.subject,
|
|
39
42
|
}),
|
|
43
|
+
cacheTtlSeconds,
|
|
40
44
|
async () => {
|
|
41
|
-
const delegationTtlSeconds = delegationLifetime(sourceToken)
|
|
45
|
+
const delegationTtlSeconds = delegationLifetime(sourceToken, exchangeTtlSeconds)
|
|
42
46
|
const client = new Client({ url: `${kernelIssuer}/invoke`, fetch, timeoutMs })
|
|
43
47
|
try {
|
|
44
48
|
const authenticated = client.as(sourceToken)
|
|
@@ -69,7 +73,14 @@ export function createExchangeCredentialResolver(
|
|
|
69
73
|
}
|
|
70
74
|
if (envelope === undefined) throw new Error('Token delegation returned no credential.')
|
|
71
75
|
return {
|
|
72
|
-
...(await exchange(
|
|
76
|
+
...(await exchange(
|
|
77
|
+
target.domainIssuer,
|
|
78
|
+
kernelIssuer,
|
|
79
|
+
envelope,
|
|
80
|
+
cacheTtlSeconds,
|
|
81
|
+
fetch,
|
|
82
|
+
signal,
|
|
83
|
+
)),
|
|
73
84
|
user: user.id,
|
|
74
85
|
sourceIssuer: sourceIdentity.issuer,
|
|
75
86
|
sourceSubject: sourceIdentity.subject,
|
|
@@ -107,7 +118,7 @@ function unknownFunctionOutcome(cause: unknown): boolean {
|
|
|
107
118
|
return (error.reason as { readonly code?: unknown }).code === 'FUNCTION_OUTCOME_UNKNOWN'
|
|
108
119
|
}
|
|
109
120
|
|
|
110
|
-
function delegationLifetime(sourceToken: string): number {
|
|
121
|
+
function delegationLifetime(sourceToken: string, requiredTtlSeconds: number): number {
|
|
111
122
|
const expiresAt = credential.inspect(sourceToken).claims.exp
|
|
112
123
|
if (typeof expiresAt !== 'number' || !Number.isSafeInteger(expiresAt)) {
|
|
113
124
|
throw new AstraleError(
|
|
@@ -115,20 +126,28 @@ function delegationLifetime(sourceToken: string): number {
|
|
|
115
126
|
'The source identity credential has no valid expiration.',
|
|
116
127
|
)
|
|
117
128
|
}
|
|
118
|
-
const remaining = expiresAt
|
|
129
|
+
const remaining = remainingCredentialLifetimeSeconds(expiresAt)
|
|
119
130
|
if (!Number.isSafeInteger(remaining) || remaining < 1) {
|
|
120
131
|
throw new AstraleError(
|
|
121
132
|
'TOKEN_EXCHANGE_SOURCE_EXPIRED',
|
|
122
133
|
'The source identity credential has no lifetime available for token exchange.',
|
|
123
134
|
)
|
|
124
135
|
}
|
|
125
|
-
|
|
136
|
+
if (remaining < requiredTtlSeconds) {
|
|
137
|
+
throw new AstraleError(
|
|
138
|
+
'TOKEN_EXCHANGE_SOURCE_LIFETIME_INSUFFICIENT',
|
|
139
|
+
'The source credential cannot cover the requested command timeout.',
|
|
140
|
+
`Refresh the identity session or use a shorter --timeout; ${remaining} seconds remain but ${requiredTtlSeconds} are required.`,
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
return requiredTtlSeconds
|
|
126
144
|
}
|
|
127
145
|
|
|
128
146
|
async function exchange(
|
|
129
147
|
domainIssuer: IssuerId,
|
|
130
148
|
kernelIssuer: IssuerId,
|
|
131
149
|
envelope: string,
|
|
150
|
+
requiredTtlSeconds: number,
|
|
132
151
|
fetch: Fetch,
|
|
133
152
|
signal: AbortSignal,
|
|
134
153
|
): Promise<{ readonly credential: string; readonly expiresAt: number }> {
|
|
@@ -225,9 +244,48 @@ async function exchange(
|
|
|
225
244
|
'Token exchange returned a credential inconsistent with the requested Domain and Kernel.',
|
|
226
245
|
)
|
|
227
246
|
}
|
|
247
|
+
const remaining = effectiveExchangeLifetime(inspected, exchanged.expiresAt)
|
|
248
|
+
if (remaining < requiredTtlSeconds) {
|
|
249
|
+
throw new AstraleError(
|
|
250
|
+
'TOKEN_EXCHANGE_LIFETIME_INSUFFICIENT',
|
|
251
|
+
'The Domain exchange credential cannot cover the requested command timeout.',
|
|
252
|
+
`The Domain issuer returned ${Math.max(0, remaining)} seconds but ${requiredTtlSeconds} are required. Use a shorter --timeout or update the Domain execution service.`,
|
|
253
|
+
)
|
|
254
|
+
}
|
|
228
255
|
return Object.freeze({ credential: exchanged.token, expiresAt: exchanged.expiresAt })
|
|
229
256
|
}
|
|
230
257
|
|
|
258
|
+
/** The outer Domain bearer and its carried Kernel proof must both survive the operation. */
|
|
259
|
+
function effectiveExchangeLifetime(
|
|
260
|
+
inspected: ReturnType<typeof credential.inspect>,
|
|
261
|
+
outerExpiresAt: number,
|
|
262
|
+
): number {
|
|
263
|
+
let proofExpiresAt: number
|
|
264
|
+
try {
|
|
265
|
+
const carried = grant.acceptUnresolved(inspected.claims.grant).expr
|
|
266
|
+
if (
|
|
267
|
+
carried.kind !== 'identity' ||
|
|
268
|
+
!('credential' in carried) ||
|
|
269
|
+
typeof carried.credential !== 'string'
|
|
270
|
+
) {
|
|
271
|
+
throw new TypeError('Domain credential does not carry an identity proof.')
|
|
272
|
+
}
|
|
273
|
+
const value = credential.inspect(carried.credential).claims.exp
|
|
274
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value)) {
|
|
275
|
+
throw new TypeError('Domain credential carries an identity proof without an expiration.')
|
|
276
|
+
}
|
|
277
|
+
proofExpiresAt = value
|
|
278
|
+
} catch (cause) {
|
|
279
|
+
if (!(cause instanceof TypeError)) throw cause
|
|
280
|
+
throw new AstraleError(
|
|
281
|
+
'TOKEN_EXCHANGE_PROTOCOL_ERROR',
|
|
282
|
+
'Token exchange returned an invalid carried identity proof.',
|
|
283
|
+
cause.message,
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
return remainingCredentialLifetimeSeconds(Math.min(outerExpiresAt, proofExpiresAt))
|
|
287
|
+
}
|
|
288
|
+
|
|
231
289
|
async function fetchExchange(
|
|
232
290
|
fetch: Fetch,
|
|
233
291
|
input: string,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const MINIMUM_CREDENTIAL_TTL_SECONDS = 60
|
|
2
|
+
const INVOCATION_RECEIPT_MARGIN_SECONDS = 5
|
|
3
|
+
const CACHE_HANDOFF_MARGIN_SECONDS = 5
|
|
4
|
+
const TOKEN_EXCHANGE_SETTLEMENT_MARGIN_SECONDS = 15
|
|
5
|
+
|
|
6
|
+
/** Cover the complete command deadline while preserving the existing short-command floor. */
|
|
7
|
+
export function invocationCredentialTtlSeconds(timeoutMs: number): number {
|
|
8
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
|
|
9
|
+
throw new TypeError('Invocation credential timeout must be a positive safe integer.')
|
|
10
|
+
}
|
|
11
|
+
return Math.max(
|
|
12
|
+
MINIMUM_CREDENTIAL_TTL_SECONDS,
|
|
13
|
+
Math.ceil(timeoutMs / 1_000) + INVOCATION_RECEIPT_MARGIN_SECONDS,
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Leave the final carrier lifetime intact after source delegation and issuer exchange settle. */
|
|
18
|
+
export function exchangeCredentialTtlSeconds(timeoutMs: number): number {
|
|
19
|
+
return invocationCredentialTtlSeconds(timeoutMs) + TOKEN_EXCHANGE_SETTLEMENT_MARGIN_SECONDS
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Refresh a cached token before the final Session carrier can cross a second boundary. */
|
|
23
|
+
export function cachedCredentialTtlSeconds(timeoutMs: number): number {
|
|
24
|
+
return invocationCredentialTtlSeconds(timeoutMs) + CACHE_HANDOFF_MARGIN_SECONDS
|
|
25
|
+
}
|