@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
|
@@ -13,6 +13,9 @@ import { ensureFreshSession } from '../idp-session'
|
|
|
13
13
|
|
|
14
14
|
const scenario = process.argv[2]
|
|
15
15
|
const audience = process.env.DRIVER_AUDIENCE || undefined
|
|
16
|
+
const minimumRemainingSeconds = process.env.DRIVER_MINIMUM_SECONDS
|
|
17
|
+
? Number(process.env.DRIVER_MINIMUM_SECONDS)
|
|
18
|
+
: undefined
|
|
16
19
|
let orgHintCalls = 0
|
|
17
20
|
const resolveOrganizationId = async (): Promise<string | undefined> => {
|
|
18
21
|
orgHintCalls += 1
|
|
@@ -25,12 +28,16 @@ function print(result: Record<string, unknown>): void {
|
|
|
25
28
|
|
|
26
29
|
try {
|
|
27
30
|
if (scenario === 'ensure') {
|
|
28
|
-
const session = await ensureFreshSession('alice', {
|
|
31
|
+
const session = await ensureFreshSession('alice', {
|
|
32
|
+
audience,
|
|
33
|
+
minimumRemainingSeconds,
|
|
34
|
+
resolveOrganizationId,
|
|
35
|
+
})
|
|
29
36
|
print({ ok: true, token: accessTokenForAudience(session, audience), orgHintCalls })
|
|
30
37
|
} else if (scenario === 'ensure-concurrent') {
|
|
31
38
|
const [a, b] = await Promise.all([
|
|
32
|
-
ensureFreshSession('alice', { audience, resolveOrganizationId }),
|
|
33
|
-
ensureFreshSession('alice', { audience, resolveOrganizationId }),
|
|
39
|
+
ensureFreshSession('alice', { audience, minimumRemainingSeconds, resolveOrganizationId }),
|
|
40
|
+
ensureFreshSession('alice', { audience, minimumRemainingSeconds, resolveOrganizationId }),
|
|
34
41
|
])
|
|
35
42
|
print({
|
|
36
43
|
ok: true,
|
|
@@ -80,6 +80,31 @@ describe('ensureFreshSession', () => {
|
|
|
80
80
|
}
|
|
81
81
|
})
|
|
82
82
|
|
|
83
|
+
test('refreshes at the conservative whole-second lifetime boundary', async () => {
|
|
84
|
+
const server = rotationServer()
|
|
85
|
+
try {
|
|
86
|
+
await writeIdpConfig(server.url)
|
|
87
|
+
const shortToken = unsignedJwt({ aud: AUD, exp: Math.ceil(Date.now() / 1_000) + 200 })
|
|
88
|
+
await writeSession({
|
|
89
|
+
access_token: shortToken,
|
|
90
|
+
// The loose millisecond timestamp appears sufficient. Delegation uses
|
|
91
|
+
// the JWT exp and its conservative second-boundary handoff instead.
|
|
92
|
+
expires_at: new Date(Date.now() + 201_000).toISOString(),
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const result = await runDriver('ensure', {
|
|
96
|
+
DRIVER_AUDIENCE: AUD,
|
|
97
|
+
DRIVER_MINIMUM_SECONDS: '200',
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
expect(result.ok).toBe(true)
|
|
101
|
+
expect(server.refreshCount()).toBe(1)
|
|
102
|
+
expect(result.token).not.toBe(shortToken)
|
|
103
|
+
} finally {
|
|
104
|
+
await server.stop()
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
|
|
83
108
|
test('refreshes with the client that issued the session instead of the IdP default', async () => {
|
|
84
109
|
const server = rotationServer()
|
|
85
110
|
try {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Whole seconds safely available after second-boundary rounding and carrier handoff. */
|
|
2
|
+
export function remainingCredentialLifetimeSeconds(
|
|
3
|
+
expiresAtEpochSeconds: number,
|
|
4
|
+
nowEpochSeconds = Math.ceil(Date.now() / 1_000),
|
|
5
|
+
): number {
|
|
6
|
+
if (!Number.isSafeInteger(expiresAtEpochSeconds) || !Number.isSafeInteger(nowEpochSeconds)) {
|
|
7
|
+
throw new TypeError('Credential expiration and current time must be safe epoch seconds.')
|
|
8
|
+
}
|
|
9
|
+
return expiresAtEpochSeconds - nowEpochSeconds - 1
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function credentialLifetimeCovers(
|
|
13
|
+
expiresAtEpochSeconds: number,
|
|
14
|
+
minimumRemainingSeconds: number,
|
|
15
|
+
nowEpochSeconds?: number,
|
|
16
|
+
): boolean {
|
|
17
|
+
if (!Number.isSafeInteger(minimumRemainingSeconds) || minimumRemainingSeconds < 1) {
|
|
18
|
+
throw new TypeError('Credential minimum lifetime must be a positive safe integer.')
|
|
19
|
+
}
|
|
20
|
+
return (
|
|
21
|
+
remainingCredentialLifetimeSeconds(expiresAtEpochSeconds, nowEpochSeconds) >=
|
|
22
|
+
minimumRemainingSeconds
|
|
23
|
+
)
|
|
24
|
+
}
|
package/src/lib/idp-session.ts
CHANGED
|
@@ -36,6 +36,8 @@ export class IdpSessionNoRefreshTokenError extends Error {
|
|
|
36
36
|
export type EnsureFreshSessionOptions = {
|
|
37
37
|
audience?: string
|
|
38
38
|
organizationId?: string
|
|
39
|
+
/** Minimum source-token lifetime required before the caller starts its operation. */
|
|
40
|
+
minimumRemainingSeconds?: number
|
|
39
41
|
/**
|
|
40
42
|
* Org-hint resolver, consulted only when a refresh actually happens.
|
|
41
43
|
* Defaults to `fetchOrgHint`; injectable for tests.
|
|
@@ -63,9 +65,10 @@ export async function ensureFreshSession(
|
|
|
63
65
|
identityName: string,
|
|
64
66
|
opts: EnsureFreshSessionOptions = {},
|
|
65
67
|
): Promise<IdpSession> {
|
|
68
|
+
const minimumRemainingMs = minimumLifetimeMs(opts.minimumRemainingSeconds)
|
|
66
69
|
const session = await readIdpSession(identityName)
|
|
67
70
|
if (!session) throw new IdpSessionMissingError(identityName)
|
|
68
|
-
if (accessTokenForAudience(session, opts.audience)) return session
|
|
71
|
+
if (accessTokenForAudience(session, opts.audience, minimumRemainingMs)) return session
|
|
69
72
|
if (!session.refresh_token) throw new IdpSessionNoRefreshTokenError(identityName)
|
|
70
73
|
|
|
71
74
|
return withFileLock(idpSessionLockPath(identityName), async () => {
|
|
@@ -73,7 +76,7 @@ export async function ensureFreshSession(
|
|
|
73
76
|
// already rotated the session — using its result is the whole point.
|
|
74
77
|
const current = await readIdpSession(identityName)
|
|
75
78
|
if (!current) throw new IdpSessionMissingError(identityName)
|
|
76
|
-
if (accessTokenForAudience(current, opts.audience)) return current
|
|
79
|
+
if (accessTokenForAudience(current, opts.audience, minimumRemainingMs)) return current
|
|
77
80
|
|
|
78
81
|
// Org resolution order: explicit > bookmarked-at-create > router lookup.
|
|
79
82
|
const bookmarkOrg =
|
|
@@ -106,7 +109,13 @@ export async function ensureFreshSession(
|
|
|
106
109
|
})
|
|
107
110
|
}
|
|
108
111
|
}
|
|
109
|
-
const rescued = await rescueAfterInvalidGrant(
|
|
112
|
+
const rescued = await rescueAfterInvalidGrant(
|
|
113
|
+
identityName,
|
|
114
|
+
current,
|
|
115
|
+
opts.audience,
|
|
116
|
+
minimumRemainingMs,
|
|
117
|
+
e,
|
|
118
|
+
)
|
|
110
119
|
if (rescued) return rescued
|
|
111
120
|
throw e
|
|
112
121
|
}
|
|
@@ -124,11 +133,24 @@ async function rescueAfterInvalidGrant(
|
|
|
124
133
|
identityName: string,
|
|
125
134
|
seen: IdpSession,
|
|
126
135
|
audience: string | undefined,
|
|
136
|
+
minimumRemainingMs: number,
|
|
127
137
|
error: unknown,
|
|
128
138
|
): Promise<IdpSession | undefined> {
|
|
129
139
|
if (!(error instanceof OAuthTokenError) || error.code !== 'invalid_grant') return undefined
|
|
130
140
|
const latest = await readIdpSession(identityName).catch(() => null)
|
|
131
141
|
if (!latest || latest.updatedAt === seen.updatedAt) return undefined
|
|
132
|
-
if (!accessTokenForAudience(latest, audience)) return undefined
|
|
142
|
+
if (!accessTokenForAudience(latest, audience, minimumRemainingMs)) return undefined
|
|
133
143
|
return latest
|
|
134
144
|
}
|
|
145
|
+
|
|
146
|
+
function minimumLifetimeMs(input: number | undefined): number {
|
|
147
|
+
const seconds = input ?? 60
|
|
148
|
+
if (
|
|
149
|
+
!Number.isSafeInteger(seconds) ||
|
|
150
|
+
seconds < 1 ||
|
|
151
|
+
seconds > Math.floor(Number.MAX_SAFE_INTEGER / 1_000)
|
|
152
|
+
) {
|
|
153
|
+
throw new TypeError('IdP session minimum lifetime must be a positive safe integer.')
|
|
154
|
+
}
|
|
155
|
+
return seconds * 1_000
|
|
156
|
+
}
|
package/src/lib/idp.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { dirname, join } from 'node:path'
|
|
|
4
4
|
import { z } from 'zod'
|
|
5
5
|
|
|
6
6
|
import { atomicWrite, IDPS_PATH, IDP_SESSIONS_DIR, paths } from '../state/index'
|
|
7
|
+
import { credentialLifetimeCovers } from './credential-lifetime'
|
|
7
8
|
import { log } from './log'
|
|
8
9
|
import { validateName, validateUrl } from './validation'
|
|
9
10
|
|
|
@@ -620,18 +621,37 @@ export function isSessionExpired(
|
|
|
620
621
|
* top-level token's own `aud` claim. Without an `audience`, freshness of the
|
|
621
622
|
* top-level token is the only requirement.
|
|
622
623
|
*/
|
|
623
|
-
export function accessTokenForAudience(
|
|
624
|
+
export function accessTokenForAudience(
|
|
625
|
+
session: IdpSession,
|
|
626
|
+
audience?: string,
|
|
627
|
+
minimumRemainingMs = 60_000,
|
|
628
|
+
): string | undefined {
|
|
624
629
|
if (audience === undefined) {
|
|
625
|
-
return
|
|
630
|
+
return tokenHasMinimumLifetime(session, minimumRemainingMs) ? session.access_token : undefined
|
|
626
631
|
}
|
|
627
632
|
const entry = session.tokens?.[audience]
|
|
628
|
-
if (entry &&
|
|
629
|
-
if (
|
|
633
|
+
if (entry && tokenHasMinimumLifetime(entry, minimumRemainingMs)) return entry.access_token
|
|
634
|
+
if (
|
|
635
|
+
tokenHasMinimumLifetime(session, minimumRemainingMs) &&
|
|
636
|
+
tokenAudienceMatches(session.access_token, audience)
|
|
637
|
+
) {
|
|
630
638
|
return session.access_token
|
|
631
639
|
}
|
|
632
640
|
return undefined
|
|
633
641
|
}
|
|
634
642
|
|
|
643
|
+
/** Prefer the JWT expiration used by delegation; opaque IdP tokens retain their timestamp path. */
|
|
644
|
+
function tokenHasMinimumLifetime(
|
|
645
|
+
value: Pick<IdpSession, 'expires_at'> & { access_token: string },
|
|
646
|
+
minimumRemainingMs: number,
|
|
647
|
+
): boolean {
|
|
648
|
+
const expiration = decodeTokenClaims(value.access_token)?.exp
|
|
649
|
+
if (typeof expiration === 'number' && Number.isSafeInteger(expiration)) {
|
|
650
|
+
return credentialLifetimeCovers(expiration, Math.ceil(minimumRemainingMs / 1_000))
|
|
651
|
+
}
|
|
652
|
+
return !isSessionExpired(value, minimumRemainingMs)
|
|
653
|
+
}
|
|
654
|
+
|
|
635
655
|
/**
|
|
636
656
|
* Fold a freshly minted access token into the per-audience map under every
|
|
637
657
|
* `aud` it carries, dropping entries that have already expired.
|
|
@@ -40,6 +40,7 @@ describe('exchange credential cache', () => {
|
|
|
40
40
|
const resolve = (candidate: typeof first) =>
|
|
41
41
|
cache.getOrRefresh(
|
|
42
42
|
candidate,
|
|
43
|
+
30,
|
|
43
44
|
async () => {
|
|
44
45
|
refreshes += 1
|
|
45
46
|
return entry(candidate, 200)
|
|
@@ -69,9 +70,9 @@ describe('exchange credential cache', () => {
|
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
const values = await Promise.all([
|
|
72
|
-
left.getOrRefresh(candidate, refresh, () => 100),
|
|
73
|
-
left.getOrRefresh(candidate, refresh, () => 100),
|
|
74
|
-
right.getOrRefresh(candidate, refresh, () => 100),
|
|
73
|
+
left.getOrRefresh(candidate, 30, refresh, () => 100),
|
|
74
|
+
left.getOrRefresh(candidate, 30, refresh, () => 100),
|
|
75
|
+
right.getOrRefresh(candidate, 30, refresh, () => 100),
|
|
75
76
|
])
|
|
76
77
|
expect(new Set(values).size).toBe(1)
|
|
77
78
|
expect(refreshes).toBe(1)
|
|
@@ -83,12 +84,14 @@ describe('exchange credential cache', () => {
|
|
|
83
84
|
const candidate = key('https://kernel.example', 'https://domain.example', 'user')
|
|
84
85
|
await cache.getOrRefresh(
|
|
85
86
|
candidate,
|
|
87
|
+
30,
|
|
86
88
|
async () => entry(candidate, 120),
|
|
87
89
|
() => 50,
|
|
88
90
|
)
|
|
89
91
|
let refreshes = 0
|
|
90
92
|
await cache.getOrRefresh(
|
|
91
93
|
candidate,
|
|
94
|
+
30,
|
|
92
95
|
async () => {
|
|
93
96
|
refreshes += 1
|
|
94
97
|
return entry(candidate, 220)
|
|
@@ -100,6 +103,7 @@ describe('exchange credential cache', () => {
|
|
|
100
103
|
await expect(
|
|
101
104
|
cache.getOrRefresh(
|
|
102
105
|
key('https://other-kernel.example', candidate.domainIssuer, candidate.sourceSubject),
|
|
106
|
+
30,
|
|
103
107
|
async () => entry(candidate, 220),
|
|
104
108
|
() => 100,
|
|
105
109
|
),
|
|
@@ -108,6 +112,7 @@ describe('exchange credential cache', () => {
|
|
|
108
112
|
await expect(
|
|
109
113
|
cache.getOrRefresh(
|
|
110
114
|
key(candidate.kernelIssuer, candidate.domainIssuer, 'other-source'),
|
|
115
|
+
30,
|
|
111
116
|
async () => entry(candidate, 220),
|
|
112
117
|
() => 100,
|
|
113
118
|
),
|
|
@@ -122,6 +127,7 @@ describe('exchange credential cache', () => {
|
|
|
122
127
|
await expect(
|
|
123
128
|
cache.getOrRefresh(
|
|
124
129
|
malformedCandidate,
|
|
130
|
+
30,
|
|
125
131
|
async () => entry(malformedCandidate, 220, malformed),
|
|
126
132
|
() => 100,
|
|
127
133
|
),
|
|
@@ -137,6 +143,7 @@ describe('exchange credential cache', () => {
|
|
|
137
143
|
for (const candidate of [a, b]) {
|
|
138
144
|
await cache.getOrRefresh(
|
|
139
145
|
candidate,
|
|
146
|
+
30,
|
|
140
147
|
async () => entry(candidate, 200),
|
|
141
148
|
() => 100,
|
|
142
149
|
)
|
|
@@ -162,6 +169,7 @@ describe('exchange credential cache', () => {
|
|
|
162
169
|
let refreshes = 0
|
|
163
170
|
await cache.getOrRefresh(
|
|
164
171
|
candidate,
|
|
172
|
+
30,
|
|
165
173
|
async () => {
|
|
166
174
|
refreshes += 1
|
|
167
175
|
return entry(candidate, 200)
|
|
@@ -173,6 +181,103 @@ describe('exchange credential cache', () => {
|
|
|
173
181
|
expect(stored.version).toBe(2)
|
|
174
182
|
expect(JSON.stringify(stored)).not.toContain('legacy')
|
|
175
183
|
})
|
|
184
|
+
|
|
185
|
+
test('refreshes a valid cached credential that cannot cover the requested invocation', async () => {
|
|
186
|
+
const cache = new ExchangeCredentialCache(path)
|
|
187
|
+
const candidate = key('https://kernel.example', 'https://domain.example', 'user')
|
|
188
|
+
await cache.getOrRefresh(
|
|
189
|
+
candidate,
|
|
190
|
+
30,
|
|
191
|
+
async () => entry(candidate, 250),
|
|
192
|
+
() => 100,
|
|
193
|
+
)
|
|
194
|
+
let refreshes = 0
|
|
195
|
+
|
|
196
|
+
await expect(
|
|
197
|
+
cache.getOrRefresh(
|
|
198
|
+
candidate,
|
|
199
|
+
185,
|
|
200
|
+
async () => {
|
|
201
|
+
refreshes += 1
|
|
202
|
+
return entry(candidate, 300)
|
|
203
|
+
},
|
|
204
|
+
() => 100,
|
|
205
|
+
),
|
|
206
|
+
).resolves.toBe(token(candidate, 300))
|
|
207
|
+
expect(refreshes).toBe(1)
|
|
208
|
+
|
|
209
|
+
await expect(
|
|
210
|
+
cache.getOrRefresh(
|
|
211
|
+
candidate,
|
|
212
|
+
201,
|
|
213
|
+
async () => entry(candidate, 190),
|
|
214
|
+
() => 100,
|
|
215
|
+
),
|
|
216
|
+
).rejects.toThrow(/required lifetime/i)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test('rejects a long outer token whose carried proof cannot cover the requested invocation', async () => {
|
|
220
|
+
const cache = new ExchangeCredentialCache(path)
|
|
221
|
+
const candidate = key('https://kernel.example', 'https://domain.example', 'user')
|
|
222
|
+
|
|
223
|
+
await expect(
|
|
224
|
+
cache.getOrRefresh(
|
|
225
|
+
candidate,
|
|
226
|
+
185,
|
|
227
|
+
async () => entry(candidate, 300, undefined, 150),
|
|
228
|
+
() => 100,
|
|
229
|
+
),
|
|
230
|
+
).rejects.toThrow(/required lifetime/i)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
test('serializes concurrent short and long callers without serving a short carrier to the long caller', async () => {
|
|
234
|
+
for (const firstKind of ['short', 'long'] as const) {
|
|
235
|
+
const concurrentPath = join(directory, firstKind, 'credentials.json')
|
|
236
|
+
const firstCache = new ExchangeCredentialCache(concurrentPath)
|
|
237
|
+
const secondCache = new ExchangeCredentialCache(concurrentPath)
|
|
238
|
+
const candidate = key('https://kernel.example', 'https://domain.example', firstKind)
|
|
239
|
+
let releaseFirst!: () => void
|
|
240
|
+
let markFirstStarted!: () => void
|
|
241
|
+
const firstStarted = new Promise<void>((resolve) => {
|
|
242
|
+
markFirstStarted = resolve
|
|
243
|
+
})
|
|
244
|
+
const release = new Promise<void>((resolve) => {
|
|
245
|
+
releaseFirst = resolve
|
|
246
|
+
})
|
|
247
|
+
let refreshes = 0
|
|
248
|
+
const firstMinimum = firstKind === 'short' ? 30 : 185
|
|
249
|
+
const firstExpiration = firstKind === 'short' ? 200 : 300
|
|
250
|
+
const first = firstCache.getOrRefresh(
|
|
251
|
+
candidate,
|
|
252
|
+
firstMinimum,
|
|
253
|
+
async () => {
|
|
254
|
+
refreshes += 1
|
|
255
|
+
markFirstStarted()
|
|
256
|
+
await release
|
|
257
|
+
return entry(candidate, firstExpiration)
|
|
258
|
+
},
|
|
259
|
+
() => 100,
|
|
260
|
+
)
|
|
261
|
+
await firstStarted
|
|
262
|
+
const secondMinimum = firstKind === 'short' ? 185 : 30
|
|
263
|
+
const secondExpiration = firstKind === 'short' ? 300 : 200
|
|
264
|
+
const second = secondCache.getOrRefresh(
|
|
265
|
+
candidate,
|
|
266
|
+
secondMinimum,
|
|
267
|
+
async () => {
|
|
268
|
+
refreshes += 1
|
|
269
|
+
return entry(candidate, secondExpiration)
|
|
270
|
+
},
|
|
271
|
+
() => 100,
|
|
272
|
+
)
|
|
273
|
+
releaseFirst()
|
|
274
|
+
|
|
275
|
+
const [firstValue, secondValue] = await Promise.all([first, second])
|
|
276
|
+
const longValue = firstKind === 'long' ? firstValue : secondValue
|
|
277
|
+
expect(longValue).toBe(token(candidate, 300))
|
|
278
|
+
expect(refreshes).toBe(firstKind === 'short' ? 2 : 1)
|
|
279
|
+
}
|
|
280
|
+
})
|
|
176
281
|
})
|
|
177
282
|
|
|
178
283
|
function key(
|
|
@@ -193,9 +298,10 @@ function entry(
|
|
|
193
298
|
candidate: ReturnType<typeof key>,
|
|
194
299
|
expiresAt: number,
|
|
195
300
|
malformed?: 'outer-delegation' | 'proof-without-delegation',
|
|
301
|
+
proofExpiresAt = expiresAt,
|
|
196
302
|
) {
|
|
197
303
|
return {
|
|
198
|
-
credential: token(candidate, expiresAt, malformed),
|
|
304
|
+
credential: token(candidate, expiresAt, malformed, proofExpiresAt),
|
|
199
305
|
expiresAt,
|
|
200
306
|
user: candidate.sourceSubject,
|
|
201
307
|
sourceIssuer: candidate.sourceIssuer,
|
|
@@ -207,13 +313,14 @@ function token(
|
|
|
207
313
|
candidate: ReturnType<typeof key>,
|
|
208
314
|
exp: number,
|
|
209
315
|
malformed?: 'outer-delegation' | 'proof-without-delegation',
|
|
316
|
+
proofExp = exp,
|
|
210
317
|
): string {
|
|
211
318
|
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url')
|
|
212
319
|
const proof = `${encode({ alg: 'EdDSA', typ: 'JWT' })}.${encode({
|
|
213
320
|
iss: candidate.kernelIssuer,
|
|
214
321
|
sub: candidate.sourceSubject,
|
|
215
322
|
aud: candidate.kernelIssuer,
|
|
216
|
-
exp,
|
|
323
|
+
exp: proofExp,
|
|
217
324
|
...(malformed === 'proof-without-delegation'
|
|
218
325
|
? {}
|
|
219
326
|
: {
|
|
@@ -2,6 +2,7 @@ import { credential, grant } from '@astrale-os/sdk/auth'
|
|
|
2
2
|
import { chmod, mkdir, readFile } from 'node:fs/promises'
|
|
3
3
|
import { dirname } from 'node:path'
|
|
4
4
|
|
|
5
|
+
import { credentialLifetimeCovers } from '../lib/credential-lifetime'
|
|
5
6
|
import { atomicWrite, withFileLock } from './files'
|
|
6
7
|
import { EXCHANGE_CREDENTIALS_PATH } from './paths'
|
|
7
8
|
|
|
@@ -37,16 +38,27 @@ export class ExchangeCredentialCache {
|
|
|
37
38
|
|
|
38
39
|
getOrRefresh(
|
|
39
40
|
key: exchange.Key,
|
|
41
|
+
minimumRemainingSeconds: number,
|
|
40
42
|
refresh: () => Promise<exchange.Entry>,
|
|
41
43
|
now = () => Math.floor(Date.now() / 1_000),
|
|
42
44
|
): Promise<string> {
|
|
45
|
+
if (!Number.isSafeInteger(minimumRemainingSeconds) || minimumRemainingSeconds < 1) {
|
|
46
|
+
throw new TypeError('Exchange credential minimum lifetime must be a positive safe integer.')
|
|
47
|
+
}
|
|
43
48
|
const encoded = encodeKey(key)
|
|
44
|
-
const
|
|
49
|
+
const pendingKey = `${encoded}\0${minimumRemainingSeconds}`
|
|
50
|
+
const current = this.refreshing.get(pendingKey)
|
|
45
51
|
if (current !== undefined) return current
|
|
46
|
-
const pending = this.getOrRefreshOnce(
|
|
47
|
-
|
|
52
|
+
const pending = this.getOrRefreshOnce(
|
|
53
|
+
key,
|
|
54
|
+
encoded,
|
|
55
|
+
minimumRemainingSeconds,
|
|
56
|
+
refresh,
|
|
57
|
+
now,
|
|
58
|
+
).finally(() => {
|
|
59
|
+
this.refreshing.delete(pendingKey)
|
|
48
60
|
})
|
|
49
|
-
this.refreshing.set(
|
|
61
|
+
this.refreshing.set(pendingKey, pending)
|
|
50
62
|
return pending
|
|
51
63
|
}
|
|
52
64
|
|
|
@@ -68,6 +80,7 @@ export class ExchangeCredentialCache {
|
|
|
68
80
|
private async getOrRefreshOnce(
|
|
69
81
|
key: exchange.Key,
|
|
70
82
|
encoded: string,
|
|
83
|
+
minimumRemainingSeconds: number,
|
|
71
84
|
refresh: () => Promise<exchange.Entry>,
|
|
72
85
|
now: () => number,
|
|
73
86
|
): Promise<string> {
|
|
@@ -76,14 +89,16 @@ export class ExchangeCredentialCache {
|
|
|
76
89
|
const store = await readStore(this.path)
|
|
77
90
|
const changed = scrub(store, now())
|
|
78
91
|
const cached = store.entries[encoded]
|
|
79
|
-
if (cached !== undefined && validEntry(key, cached, now())) {
|
|
92
|
+
if (cached !== undefined && validEntry(key, cached, now(), minimumRemainingSeconds)) {
|
|
80
93
|
if (changed) await writeStore(this.path, store)
|
|
81
94
|
return cached.credential
|
|
82
95
|
}
|
|
83
96
|
|
|
84
97
|
const next = await refresh()
|
|
85
|
-
if (!validEntry(key, next, now(),
|
|
86
|
-
throw new Error(
|
|
98
|
+
if (!validEntry(key, next, now(), minimumRemainingSeconds)) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
'Token exchange returned a credential inconsistent with its cache key or required lifetime.',
|
|
101
|
+
)
|
|
87
102
|
}
|
|
88
103
|
store.entries[encoded] = Object.freeze({ ...next })
|
|
89
104
|
await writeStore(this.path, store)
|
|
@@ -187,7 +202,10 @@ function validEntry(
|
|
|
187
202
|
!Object.hasOwn(inspected.claims, 'delegation') &&
|
|
188
203
|
proof.iss === key.kernelIssuer &&
|
|
189
204
|
proof.sub === entry.user &&
|
|
190
|
-
proof.aud === key.kernelIssuer
|
|
205
|
+
proof.aud === key.kernelIssuer &&
|
|
206
|
+
typeof proof.claims.exp === 'number' &&
|
|
207
|
+
Number.isSafeInteger(proof.claims.exp) &&
|
|
208
|
+
credentialLifetimeCovers(proof.claims.exp, minimumRemaining, now)
|
|
191
209
|
)
|
|
192
210
|
} catch {
|
|
193
211
|
return false
|