@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.
@@ -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', { audience, resolveOrganizationId })
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
+ }
@@ -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(identityName, current, opts.audience, e)
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(session: IdpSession, audience?: string): string | undefined {
624
+ export function accessTokenForAudience(
625
+ session: IdpSession,
626
+ audience?: string,
627
+ minimumRemainingMs = 60_000,
628
+ ): string | undefined {
624
629
  if (audience === undefined) {
625
- return isSessionExpired(session) ? undefined : session.access_token
630
+ return tokenHasMinimumLifetime(session, minimumRemainingMs) ? session.access_token : undefined
626
631
  }
627
632
  const entry = session.tokens?.[audience]
628
- if (entry && !isSessionExpired(entry)) return entry.access_token
629
- if (!isSessionExpired(session) && tokenAudienceMatches(session.access_token, audience)) {
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 current = this.refreshing.get(encoded)
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(key, encoded, refresh, now).finally(() => {
47
- this.refreshing.delete(encoded)
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(encoded, pending)
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(), 1)) {
86
- throw new Error('Token exchange returned a credential inconsistent with its cache key.')
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