@mentra/cloud-client 3.2.0-dev.137 → 3.2.0-dev.154

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.
@@ -25,18 +25,14 @@
25
25
  * See docs/issues/004-cloud-client/spec.md ("cloud.auth"), design.md, and
26
26
  * docs/issues/001-cloud-core/auth/spec.md (endpoints + token shapes).
27
27
  */
28
- import type {
29
- AuthConfig,
30
- CoreAuthConfig,
31
- RuntimeAuthConfig,
32
- SubjectTokenType,
33
- } from "../../config";
34
- import type { HttpClient } from "../../http";
35
- import type { Logger } from "../../logger";
36
- import type { HttpTransport } from "../../transports";
37
- import { AuthExpiredError } from "../../errors";
38
- import { decodeClaims } from "./jwt";
39
- import { TokenStore } from "./token-store";
28
+ import type {AuthConfig, CoreAuthConfig, RuntimeAuthConfig, SubjectTokenType} from "../../config"
29
+ import type {HttpClient} from "../../http"
30
+ import type {Logger} from "../../logger"
31
+ import type {HttpTransport} from "../../transports"
32
+ import {AuthExpiredError, HttpError, SessionRevocationError} from "../../errors"
33
+ import {systemTimers, type CloudClientTimers} from "../../timers"
34
+ import {decodeClaims} from "./jwt"
35
+ import {TokenStore} from "./token-store"
40
36
 
41
37
  /**
42
38
  * The public auth surface a host uses, from spec.md.
@@ -51,85 +47,105 @@ export interface AuthModule {
51
47
  // `{ forceRefresh: true }` to bypass the in-memory cache and refresh now, for
52
48
  // the case where the cloud rejected a token the client still thinks is fresh
53
49
  // (clock skew or a mid-session revoke surfaced as AUTH_EXPIRED).
54
- getRuntimeToken(opts?: { forceRefresh?: boolean }): Promise<string>;
50
+ getRuntimeToken(opts?: {forceRefresh?: boolean}): Promise<string>
55
51
  // current Core token, refreshing as needed (Core-backed mode only).
56
- getCoreToken(opts?: { forceRefresh?: boolean }): Promise<string>;
52
+ getCoreToken(opts?: {forceRefresh?: boolean}): Promise<string>
57
53
  // a miniapp-scoped token, cached per packageName and re-minted before expiry
58
54
  getMiniappToken(
59
55
  packageName: string,
60
- opts?: { minTtlMs?: number; devAttestation?: string },
61
- ): Promise<{ token: string; expiresAt: number }>;
56
+ opts?: {minTtlMs?: number; devAttestation?: string},
57
+ ): Promise<{token: string; expiresAt: number}>
62
58
  // Core-owned user/oem identity, read from the Core access token.
63
59
  // Runtime-only deployments do not expose this surface.
64
- readonly identity: { mentraUserId: string; tenantId: string };
60
+ readonly identity: {mentraUserId: string; tenantId: string}
61
+ // clear access, refresh, runtime, and miniapp credentials for logout. Local
62
+ // credentials are always cleared; rejects with `SessionRevocationError` when
63
+ // Core did not confirm revocation of the server-side session.
64
+ clearSession(): Promise<void>
65
65
  // refresh failed; the host must send the user back through login
66
- onExpired(handler: () => void): () => void;
66
+ onExpired(handler: () => void): () => void
67
67
  }
68
68
 
69
69
  /** RFC 8693 token-exchange grant type for the `/exchange` call. */
70
- const TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange";
70
+ const TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange"
71
71
 
72
72
  /**
73
73
  * Map a config `SubjectTokenType` to the RFC 8693 `subject_token_type` URN the
74
74
  * cloud expects.
75
75
  *
76
- * All three supported subject tokens (an OEM-signed JWT, a Mentra core token, a
77
- * Supabase session) are presented as a JWT and verified by their own `iss` /
76
+ * All supported subject tokens (OIDC, an OEM-signed JWT, a Mentra core token,
77
+ * or a Supabase session) are presented as a JWT and verified by their own `iss` /
78
78
  * verification path on the cloud, so they share the one JWT token-type URN. The
79
79
  * mapping is kept explicit (rather than hard-coding one URN) so that if the
80
80
  * cloud later wants distinct URNs per source, this is the single place to widen.
81
81
  */
82
82
  const SUBJECT_TOKEN_TYPE_URN: Record<SubjectTokenType, string> = {
83
+ "oidc": "urn:ietf:params:oauth:token-type:jwt",
83
84
  "oem-jwt": "urn:ietf:params:oauth:token-type:jwt",
84
85
  "mentra-core": "urn:ietf:params:oauth:token-type:jwt",
85
- supabase: "urn:ietf:params:oauth:token-type:jwt",
86
- };
86
+ "supabase": "urn:ietf:params:oauth:token-type:jwt",
87
+ }
87
88
 
88
89
  /** Seconds of headroom before `exp` at which we proactively refresh/re-mint. */
89
- const EXPIRY_MARGIN_SECONDS = 60;
90
+ const EXPIRY_MARGIN_SECONDS = 60
91
+
92
+ /** Total attempts at the idempotent `/revoke` during logout on network/5xx failures. */
93
+ const REVOKE_ATTEMPTS = 3
94
+ /** Base backoff between revoke attempts in milliseconds; doubles per attempt. */
95
+ const REVOKE_RETRY_BASE_MS = 250
90
96
 
91
97
  /** The cloud's token endpoints, relative to the core base URL. */
92
- const EXCHANGE_PATH = "/api/client/auth/exchange";
93
- const REFRESH_PATH = "/api/client/auth/refresh";
94
- const RUNTIME_TOKEN_PATH = "/api/client/auth/runtime-token";
95
- const MINIAPP_TOKEN_PATH = "/api/client/auth/miniapp-token";
98
+ const EXCHANGE_PATH = "/api/client/auth/exchange"
99
+ const REFRESH_PATH = "/api/client/auth/refresh"
100
+ const RUNTIME_TOKEN_PATH = "/api/client/auth/runtime-token"
101
+ const MINIAPP_TOKEN_PATH = "/api/client/auth/miniapp-token"
102
+ const REVOKE_PATH = "/api/client/auth/revoke"
96
103
 
97
104
  /** Single-flight keys. The miniapp key is suffixed per packageName below. */
98
- const FLIGHT_ACCESS = "access-token";
99
- const FLIGHT_RUNTIME = "runtime-token";
100
- const MINIAPP_FLIGHT_PREFIX = "miniapp-token:";
105
+ const FLIGHT_ACCESS = "access-token"
106
+ const FLIGHT_RUNTIME = "runtime-token"
107
+ const MINIAPP_FLIGHT_PREFIX = "miniapp-token:"
101
108
 
102
109
  /** The cloud's RFC-shaped token response from `/exchange` and `/refresh`. */
103
110
  interface TokenResponse {
104
- access_token: string;
105
- refresh_token: string;
106
- token_type: string;
107
- expires_in: number;
111
+ access_token: string
112
+ refresh_token: string
113
+ token_type: string
114
+ expires_in: number
108
115
  }
109
116
 
110
117
  /** A cached miniapp token plus its absolute expiry (Unix seconds). */
111
118
  interface MiniappTokenEntry {
112
- token: string;
113
- expiresAt: number;
119
+ token: string
120
+ expiresAt: number
114
121
  }
115
122
 
116
123
  interface RuntimeTokenResponse {
117
- access_token: string;
118
- token_type: string;
119
- expires_in: number;
124
+ access_token: string
125
+ token_type: string
126
+ expires_in: number
120
127
  }
121
128
 
122
129
  interface RuntimeTokenEntry {
123
- token: string;
124
- expiresAt: number;
130
+ token: string
131
+ expiresAt: number
132
+ }
133
+
134
+ /**
135
+ * A revoke is worth retrying only when Core gave no definite answer: the
136
+ * transport failed (`status` 0) or the server errored (5xx). A 4xx means Core
137
+ * rejected the request and repeating it would not change the outcome.
138
+ */
139
+ function isRetryableRevokeError(err: unknown): boolean {
140
+ return err instanceof HttpError && (err.status === 0 || err.status >= 500)
125
141
  }
126
142
 
127
143
  export class Auth implements AuthModule {
128
- private readonly http?: HttpClient;
129
- private readonly store: TokenStore;
130
- private readonly coreConfig?: CoreAuthConfig;
131
- private readonly runtimeConfig: RuntimeAuthConfig;
132
- private readonly logger: Logger;
144
+ private readonly http?: HttpClient
145
+ private readonly store: TokenStore
146
+ private readonly coreConfig?: CoreAuthConfig
147
+ private readonly runtimeConfig: RuntimeAuthConfig
148
+ private readonly logger: Logger
133
149
  /**
134
150
  * Core base URL for the form-encoded `/exchange` and `/refresh` calls.
135
151
  *
@@ -140,38 +156,45 @@ export class Auth implements AuthModule {
140
156
  * design: Core identity, miniapp token minting, and miniapp auto-auth are
141
157
  * Core-backed features.
142
158
  */
143
- private readonly baseUrl?: string;
144
- private readonly httpTransport: HttpTransport;
159
+ private readonly baseUrl?: string
160
+ private readonly httpTransport: HttpTransport
161
+ private readonly timers: CloudClientTimers
145
162
 
146
163
  /** Miniapp tokens cached per packageName until near expiry. */
147
- private readonly miniappCache = new Map<string, MiniappTokenEntry>();
164
+ private readonly miniappCache = new Map<string, MiniappTokenEntry>()
148
165
  /** Core-brokered runtime token cache. */
149
- private runtimeToken: RuntimeTokenEntry | null = null;
166
+ private runtimeToken: RuntimeTokenEntry | null = null
150
167
 
151
168
  /** Registered `onExpired` handlers. */
152
- private readonly expiredHandlers = new Set<() => void>();
169
+ private readonly expiredHandlers = new Set<() => void>()
153
170
 
154
171
  /**
155
172
  * Latches once `onExpired` has fired, so a dead refresh token notifies the
156
173
  * host exactly once instead of on every subsequent call.
157
174
  */
158
- private expiredFired = false;
175
+ private expiredFired = false
176
+ /** Incremented on logout so late async results cannot repopulate credentials. */
177
+ private sessionGeneration = 0
178
+ /** A logged-out client stays inert until the host constructs a fresh client. */
179
+ private sessionCleared = false
159
180
 
160
181
  constructor(deps: {
161
- http?: HttpClient;
162
- store: TokenStore;
163
- config: AuthConfig;
164
- logger: Logger;
165
- baseUrl?: string;
166
- fetch?: HttpTransport;
182
+ http?: HttpClient
183
+ store: TokenStore
184
+ config: AuthConfig
185
+ logger: Logger
186
+ baseUrl?: string
187
+ fetch?: HttpTransport
188
+ timers?: CloudClientTimers
167
189
  }) {
168
- this.http = deps.http;
169
- this.store = deps.store;
170
- this.coreConfig = deps.config.core;
171
- this.runtimeConfig = deps.config.runtime;
172
- this.logger = deps.logger;
173
- this.baseUrl = deps.baseUrl;
174
- this.httpTransport = deps.fetch ?? globalThis.fetch;
190
+ this.http = deps.http
191
+ this.store = deps.store
192
+ this.coreConfig = deps.config.core
193
+ this.runtimeConfig = deps.config.runtime
194
+ this.logger = deps.logger
195
+ this.baseUrl = deps.baseUrl
196
+ this.httpTransport = deps.fetch ?? globalThis.fetch
197
+ this.timers = deps.timers ?? systemTimers
175
198
  }
176
199
 
177
200
  /**
@@ -181,39 +204,46 @@ export class Auth implements AuthModule {
181
204
  * margin is returned with no network call. Otherwise we obtain one through a
182
205
  * single-flight so concurrent callers share the same request.
183
206
  */
184
- async getRuntimeToken(opts?: { forceRefresh?: boolean }): Promise<string> {
207
+ async getRuntimeToken(opts?: {forceRefresh?: boolean}): Promise<string> {
208
+ this.assertSessionActive()
185
209
  if ("getToken" in this.runtimeConfig) {
186
- return this.runtimeConfig.getToken(opts);
210
+ const generation = this.sessionGeneration
211
+ const token = await this.runtimeConfig.getToken(opts)
212
+ this.assertSessionActive(generation)
213
+ return token
187
214
  }
188
215
 
189
216
  if (opts?.forceRefresh) {
190
- this.runtimeToken = null;
217
+ this.runtimeToken = null
191
218
  } else if (this.runtimeToken && !this.isExpiring(this.runtimeToken.expiresAt)) {
192
- return this.runtimeToken.token;
219
+ return this.runtimeToken.token
193
220
  }
194
221
 
195
- return this.store.singleFlight(FLIGHT_RUNTIME, () =>
196
- this.obtainCoreBrokeredRuntimeToken(),
197
- );
222
+ const generation = this.sessionGeneration
223
+ return this.store.singleFlight(`${FLIGHT_RUNTIME}:${generation}`, () =>
224
+ this.obtainCoreBrokeredRuntimeToken(generation),
225
+ )
198
226
  }
199
227
 
200
- async getCoreToken(opts?: { forceRefresh?: boolean }): Promise<string> {
201
- this.requireCoreConfig();
228
+ async getCoreToken(opts?: {forceRefresh?: boolean}): Promise<string> {
229
+ this.assertSessionActive()
230
+ this.requireCoreConfig()
202
231
  // A forced refresh drops the cached access token first, so the cache check
203
232
  // below misses and we go straight to the single-flight refresh. The
204
233
  // single-flight still de-dupes, so a burst of forced refreshes (one per
205
234
  // reconnect attempt) collapses to one request.
206
235
  if (opts?.forceRefresh) {
207
- this.store.invalidateAccess();
236
+ this.store.invalidateAccess()
208
237
  } else {
209
- const cached = this.store.current();
238
+ const cached = this.store.current()
210
239
  if (cached && !this.isExpiring(cached.exp)) {
211
- return cached.accessToken;
240
+ return cached.accessToken
212
241
  }
213
242
  }
214
243
 
215
244
  // De-dupe: if a refresh/exchange is already running, await that one result.
216
- return this.store.singleFlight(FLIGHT_ACCESS, () => this.obtainAccessToken());
245
+ const generation = this.sessionGeneration
246
+ return this.store.singleFlight(`${FLIGHT_ACCESS}:${generation}`, () => this.obtainAccessToken(generation))
217
247
  }
218
248
 
219
249
  /**
@@ -227,38 +257,41 @@ export class Auth implements AuthModule {
227
257
  */
228
258
  async getMiniappToken(
229
259
  packageName: string,
230
- opts?: { minTtlMs?: number; devAttestation?: string },
231
- ): Promise<{ token: string; expiresAt: number }> {
232
- const marginSeconds = this.tokenMarginSeconds(opts?.minTtlMs);
233
- const cached = this.miniappCache.get(packageName);
260
+ opts?: {minTtlMs?: number; devAttestation?: string},
261
+ ): Promise<{token: string; expiresAt: number}> {
262
+ this.assertSessionActive()
263
+ const generation = this.sessionGeneration
264
+ const marginSeconds = this.tokenMarginSeconds(opts?.minTtlMs)
265
+ const cached = this.miniappCache.get(packageName)
234
266
  if (cached && !this.isExpiring(cached.expiresAt, marginSeconds)) {
235
- return { token: cached.token, expiresAt: cached.expiresAt };
267
+ return {token: cached.token, expiresAt: cached.expiresAt}
236
268
  }
237
269
 
238
- return this.store.singleFlight(MINIAPP_FLIGHT_PREFIX + packageName, async () => {
270
+ return this.store.singleFlight(`${MINIAPP_FLIGHT_PREFIX}${packageName}:${generation}`, async () => {
239
271
  // Re-check the cache inside the flight: a concurrent mint that resolved
240
272
  // while we were queued may have already filled it.
241
- const fresh = this.miniappCache.get(packageName);
273
+ const fresh = this.miniappCache.get(packageName)
242
274
  if (fresh && !this.isExpiring(fresh.expiresAt, marginSeconds)) {
243
- return { token: fresh.token, expiresAt: fresh.expiresAt };
275
+ return {token: fresh.token, expiresAt: fresh.expiresAt}
244
276
  }
245
277
 
246
- const accessToken = await this.getCoreToken();
247
- const http = this.requireCoreHttp();
278
+ const accessToken = await this.getCoreToken()
279
+ const http = this.requireCoreHttp()
248
280
  const res = await http.post<MiniappTokenEntry>(
249
281
  MINIAPP_TOKEN_PATH,
250
282
  {
251
283
  packageName,
252
- ...(opts?.devAttestation ? { devAttestation: opts.devAttestation } : {}),
284
+ ...(opts?.devAttestation ? {devAttestation: opts.devAttestation} : {}),
253
285
  },
254
- { bearer: accessToken },
255
- );
256
-
257
- const entry: MiniappTokenEntry = { token: res.token, expiresAt: res.expiresAt };
258
- this.miniappCache.set(packageName, entry);
259
- this.logger.debug("minted miniapp token", { packageName });
260
- return { token: entry.token, expiresAt: entry.expiresAt };
261
- });
286
+ {bearer: accessToken},
287
+ )
288
+
289
+ this.assertSessionActive(generation)
290
+ const entry: MiniappTokenEntry = {token: res.token, expiresAt: res.expiresAt}
291
+ this.miniappCache.set(packageName, entry)
292
+ this.logger.debug("minted miniapp token", {packageName})
293
+ return {token: entry.token, expiresAt: entry.expiresAt}
294
+ })
262
295
  }
263
296
 
264
297
  /**
@@ -273,14 +306,74 @@ export class Auth implements AuthModule {
273
306
  * every call, so the client need not). Throws if no Core access token has been
274
307
  * obtained yet, since Core identity is meaningless before the first exchange.
275
308
  */
276
- get identity(): { mentraUserId: string; tenantId: string } {
277
- this.requireCoreConfig();
278
- const current = this.store.current();
309
+ get identity(): {mentraUserId: string; tenantId: string} {
310
+ this.requireCoreConfig()
311
+ const current = this.store.current()
279
312
  if (!current) {
280
- throw new AuthExpiredError("Core identity is unavailable before first Core sign-in");
313
+ throw new AuthExpiredError("Core identity is unavailable before first Core sign-in")
314
+ }
315
+ const claims = decodeClaims(current.accessToken)
316
+ return {mentraUserId: claims.sub, tenantId: claims.tenant_id}
317
+ }
318
+
319
+ async clearSession(): Promise<void> {
320
+ this.sessionGeneration += 1
321
+ this.sessionCleared = true
322
+ let revokeError: unknown
323
+ try {
324
+ await this.revokeCoreSession()
325
+ } catch (err) {
326
+ revokeError = err
327
+ this.logger.warn("Core session revocation failed during local logout", {
328
+ error: (err as Error)?.message,
329
+ })
330
+ } finally {
331
+ await this.store.clear()
332
+ this.runtimeToken = null
333
+ this.miniappCache.clear()
334
+ this.expiredFired = false
335
+ }
336
+ if (revokeError) {
337
+ throw new SessionRevocationError("Core session revocation failed; local credentials were cleared", {
338
+ cause: revokeError,
339
+ })
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Revoke the Core session server-side before local credentials are dropped.
345
+ *
346
+ * Core verifies the presented access token with no clock tolerance, so a
347
+ * cached token inside the usual expiry margin is refreshed first rather than
348
+ * risking a rejected revoke. `/revoke` is idempotent, so a network error or a
349
+ * 5xx is retried a bounded number of times with backoff; a definite 4xx is
350
+ * not. Any failure propagates so the caller can tell revocation did not
351
+ * complete.
352
+ */
353
+ private async revokeCoreSession(): Promise<void> {
354
+ if (!this.coreConfig || !this.http) return
355
+ const current = this.store.current()
356
+ const refreshToken = await this.store.refreshToken()
357
+ let accessToken: string | null = null
358
+ if (current && !this.isExpiring(current.exp)) {
359
+ accessToken = current.accessToken
360
+ } else if (refreshToken) {
361
+ const body = new URLSearchParams({grant_type: "refresh_token", refresh_token: refreshToken})
362
+ const tokens = await this.postForm(REFRESH_PATH, body, "logout refresh")
363
+ accessToken = tokens.access_token
364
+ }
365
+ if (!accessToken) return
366
+
367
+ for (let attempt = 0; ; attempt++) {
368
+ try {
369
+ await this.http.post(REVOKE_PATH, {}, {bearer: accessToken})
370
+ return
371
+ } catch (err) {
372
+ if (attempt + 1 >= REVOKE_ATTEMPTS || !isRetryableRevokeError(err)) throw err
373
+ this.logger.warn("Core session revocation failed; retrying", {attempt: attempt + 1})
374
+ await new Promise<void>((resolve) => this.timers.setTimeout(resolve, REVOKE_RETRY_BASE_MS * 2 ** attempt))
375
+ }
281
376
  }
282
- const claims = decodeClaims(current.accessToken);
283
- return { mentraUserId: claims.sub, tenantId: claims.tenant_id };
284
377
  }
285
378
 
286
379
  /**
@@ -291,10 +384,10 @@ export class Auth implements AuthModule {
291
384
  * `fireExpired`).
292
385
  */
293
386
  onExpired(handler: () => void): () => void {
294
- this.expiredHandlers.add(handler);
387
+ this.expiredHandlers.add(handler)
295
388
  return () => {
296
- this.expiredHandlers.delete(handler);
297
- };
389
+ this.expiredHandlers.delete(handler)
390
+ }
298
391
  }
299
392
 
300
393
  // === internals ===
@@ -307,15 +400,15 @@ export class Auth implements AuthModule {
307
400
  * in seconds and subtract the margin.
308
401
  */
309
402
  private isExpiring(expSeconds: number, marginSeconds = EXPIRY_MARGIN_SECONDS): boolean {
310
- const nowSeconds = Math.floor(Date.now() / 1000);
311
- return expSeconds - marginSeconds <= nowSeconds;
403
+ const nowSeconds = Math.floor(Date.now() / 1000)
404
+ return expSeconds - marginSeconds <= nowSeconds
312
405
  }
313
406
 
314
407
  private tokenMarginSeconds(minTtlMs?: number): number {
315
408
  if (!Number.isFinite(minTtlMs) || !minTtlMs || minTtlMs <= 0) {
316
- return EXPIRY_MARGIN_SECONDS;
409
+ return EXPIRY_MARGIN_SECONDS
317
410
  }
318
- return Math.max(EXPIRY_MARGIN_SECONDS, Math.ceil(minTtlMs / 1000));
411
+ return Math.max(EXPIRY_MARGIN_SECONDS, Math.ceil(minTtlMs / 1000))
319
412
  }
320
413
 
321
414
  /**
@@ -323,24 +416,24 @@ export class Auth implements AuthModule {
323
416
  * do the first-use exchange. Runs inside the single-flight from
324
417
  * `getCoreToken`, so only one of these is ever in flight.
325
418
  */
326
- private async obtainAccessToken(): Promise<string> {
327
- const refreshToken = await this.store.refreshToken();
419
+ private async obtainAccessToken(generation: number): Promise<string> {
420
+ const refreshToken = await this.store.refreshToken()
328
421
  if (refreshToken) {
329
422
  try {
330
- return await this.refresh(refreshToken, { deferExpired: this.canExchangeFreshSubject() });
423
+ return await this.refresh(refreshToken, {deferExpired: this.canExchangeFreshSubject(), generation})
331
424
  } catch (err) {
332
425
  if (this.canExchangeFreshSubject()) {
333
- this.logger.info("refresh failed; exchanging fresh subject token");
426
+ this.logger.info("refresh failed; exchanging fresh subject token")
334
427
  try {
335
- return await this.exchange();
428
+ return await this.exchange(generation)
336
429
  } catch {
337
- this.fireExpired();
430
+ this.fireExpired()
338
431
  }
339
432
  }
340
- throw err;
433
+ throw err
341
434
  }
342
435
  }
343
- return this.exchange();
436
+ return this.exchange(generation)
344
437
  }
345
438
 
346
439
  /**
@@ -353,36 +446,42 @@ export class Auth implements AuthModule {
353
446
  * no subject token at all: we persist its refresh token and refresh, since we
354
447
  * only reach `exchange` when no refresh token is stored.
355
448
  */
356
- private async exchange(): Promise<string> {
357
- const config = this.requireCoreConfig();
449
+ private async exchange(generation = this.sessionGeneration): Promise<string> {
450
+ const config = this.requireCoreConfig()
358
451
  // Pre-exchanged credentials: seed the store and refresh, no /exchange call.
359
452
  if ("refreshToken" in config) {
360
- await this.store.save({
361
- accessToken: config.accessToken,
362
- refreshToken: config.refreshToken,
363
- });
453
+ await this.saveTokenPair(
454
+ {
455
+ accessToken: config.accessToken,
456
+ refreshToken: config.refreshToken,
457
+ },
458
+ generation,
459
+ )
364
460
  // The seeded access token may already be near expiry, so refresh through
365
461
  // the normal path to guarantee a fresh one.
366
- return this.refresh(config.refreshToken);
462
+ return this.refresh(config.refreshToken, {generation})
367
463
  }
368
464
 
369
- const subject = await this.resolveSubjectToken(config);
465
+ const subject = await this.resolveSubjectToken(config)
370
466
  const body = new URLSearchParams({
371
467
  grant_type: TOKEN_EXCHANGE_GRANT,
372
468
  subject_token: subject.token,
373
469
  subject_token_type: SUBJECT_TOKEN_TYPE_URN[subject.type],
374
- });
375
-
376
- const tokens = await this.postForm(EXCHANGE_PATH, body, "exchange");
377
- await this.store.save({
378
- accessToken: tokens.access_token,
379
- refreshToken: tokens.refresh_token,
380
- });
470
+ })
471
+
472
+ const tokens = await this.postForm(EXCHANGE_PATH, body, "exchange")
473
+ await this.saveTokenPair(
474
+ {
475
+ accessToken: tokens.access_token,
476
+ refreshToken: tokens.refresh_token,
477
+ },
478
+ generation,
479
+ )
381
480
  // A successful exchange means credentials are live again; clear the latch so
382
481
  // a future failure can notify the host once more.
383
- this.expiredFired = false;
384
- this.logger.info("exchanged subject token for access token");
385
- return tokens.access_token;
482
+ this.expiredFired = false
483
+ this.logger.info("exchanged subject token for access token")
484
+ return tokens.access_token
386
485
  }
387
486
 
388
487
  /**
@@ -395,36 +494,39 @@ export class Auth implements AuthModule {
395
494
  * `AuthExpiredError`. We do not retry refresh forever: a dead refresh token
396
495
  * will not heal on its own, and retrying would loop.
397
496
  */
398
- private async refresh(refreshToken: string, opts?: { deferExpired?: boolean }): Promise<string> {
497
+ private async refresh(refreshToken: string, opts?: {deferExpired?: boolean; generation?: number}): Promise<string> {
399
498
  const body = new URLSearchParams({
400
499
  grant_type: "refresh_token",
401
500
  refresh_token: refreshToken,
402
- });
501
+ })
403
502
 
404
- let tokens: TokenResponse;
503
+ let tokens: TokenResponse
405
504
  try {
406
- tokens = await this.postForm(REFRESH_PATH, body, "refresh");
505
+ tokens = await this.postForm(REFRESH_PATH, body, "refresh")
407
506
  } catch {
408
507
  // The refresh token is unusable: drop it so we do not keep presenting a
409
508
  // known-bad token.
410
- await this.store.clear();
509
+ await this.store.clear()
411
510
  if (!opts?.deferExpired) {
412
- this.fireExpired();
511
+ this.fireExpired()
413
512
  }
414
- throw new AuthExpiredError("token refresh failed; re-auth required");
513
+ throw new AuthExpiredError("token refresh failed; re-auth required")
415
514
  }
416
515
 
417
- await this.store.save({
418
- accessToken: tokens.access_token,
419
- refreshToken: tokens.refresh_token,
420
- });
421
- this.expiredFired = false;
422
- this.logger.debug("refreshed access token");
423
- return tokens.access_token;
516
+ await this.saveTokenPair(
517
+ {
518
+ accessToken: tokens.access_token,
519
+ refreshToken: tokens.refresh_token,
520
+ },
521
+ opts?.generation ?? this.sessionGeneration,
522
+ )
523
+ this.expiredFired = false
524
+ this.logger.debug("refreshed access token")
525
+ return tokens.access_token
424
526
  }
425
527
 
426
528
  private canExchangeFreshSubject(): boolean {
427
- return !!this.coreConfig && "getSubjectToken" in this.coreConfig;
529
+ return !!this.coreConfig && "getSubjectToken" in this.coreConfig
428
530
  }
429
531
 
430
532
  /**
@@ -434,52 +536,61 @@ export class Auth implements AuthModule {
434
536
  * Only reached for the two non-pre-exchanged config shapes; the caller handles
435
537
  * the pre-exchanged shape before us, so the final throw is just exhaustiveness.
436
538
  */
437
- private async resolveSubjectToken(
438
- config: CoreAuthConfig,
439
- ): Promise<{ token: string; type: SubjectTokenType }> {
539
+ private async resolveSubjectToken(config: CoreAuthConfig): Promise<{token: string; type: SubjectTokenType}> {
440
540
  if ("subjectToken" in config) {
441
- return { token: config.subjectToken, type: config.subjectTokenType };
541
+ return {token: config.subjectToken, type: config.subjectTokenType}
442
542
  }
443
543
  if ("getSubjectToken" in config) {
444
- return config.getSubjectToken();
544
+ return config.getSubjectToken()
445
545
  }
446
- throw new AuthExpiredError("no subject token available to exchange");
546
+ throw new AuthExpiredError("no subject token available to exchange")
447
547
  }
448
548
 
449
549
  private requireCoreConfig(): CoreAuthConfig {
450
550
  if (!this.coreConfig) {
451
- throw new AuthExpiredError("core auth is not configured; this API is unavailable in runtime-only mode");
551
+ throw new AuthExpiredError("core auth is not configured; this API is unavailable in runtime-only mode")
452
552
  }
453
- return this.coreConfig;
553
+ return this.coreConfig
454
554
  }
455
555
 
456
556
  private requireCoreHttp(): HttpClient {
457
557
  if (!this.http) {
458
- throw new AuthExpiredError("core endpoint is not configured; this API is unavailable in runtime-only mode");
558
+ throw new AuthExpiredError("core endpoint is not configured; this API is unavailable in runtime-only mode")
459
559
  }
460
- return this.http;
560
+ return this.http
461
561
  }
462
562
 
463
- private async obtainCoreBrokeredRuntimeToken(): Promise<string> {
464
- const fresh = this.runtimeToken;
563
+ private async obtainCoreBrokeredRuntimeToken(generation: number): Promise<string> {
564
+ const fresh = this.runtimeToken
465
565
  if (fresh && !this.isExpiring(fresh.expiresAt)) {
466
- return fresh.token;
566
+ return fresh.token
467
567
  }
468
568
 
469
- const coreToken = await this.getCoreToken();
470
- const http = this.requireCoreHttp();
471
- const res = await http.post<RuntimeTokenResponse>(
472
- RUNTIME_TOKEN_PATH,
473
- {},
474
- { bearer: coreToken },
475
- );
569
+ const coreToken = await this.getCoreToken()
570
+ const http = this.requireCoreHttp()
571
+ const res = await http.post<RuntimeTokenResponse>(RUNTIME_TOKEN_PATH, {}, {bearer: coreToken})
476
572
  if (res.token_type !== "Bearer" || !res.access_token) {
477
- throw new AuthExpiredError("core returned an invalid runtime token response");
573
+ throw new AuthExpiredError("core returned an invalid runtime token response")
478
574
  }
479
575
 
480
- const expiresAt = Math.floor(Date.now() / 1000) + res.expires_in;
481
- this.runtimeToken = { token: res.access_token, expiresAt };
482
- return res.access_token;
576
+ this.assertSessionActive(generation)
577
+ const expiresAt = Math.floor(Date.now() / 1000) + res.expires_in
578
+ this.runtimeToken = {token: res.access_token, expiresAt}
579
+ return res.access_token
580
+ }
581
+
582
+ /** Persist a token pair without allowing a concurrent logout to restore it. */
583
+ private async saveTokenPair(tokens: {accessToken: string; refreshToken: string}, generation: number): Promise<void> {
584
+ this.assertSessionActive(generation)
585
+ await this.store.save(tokens)
586
+ try {
587
+ this.assertSessionActive(generation)
588
+ } catch (error) {
589
+ // A storage adapter may have completed its write after logout cleared the
590
+ // key. Clear once more so the late write cannot resurrect the session.
591
+ await this.store.clear()
592
+ throw error
593
+ }
483
594
  }
484
595
 
485
596
  /**
@@ -491,27 +602,23 @@ export class Auth implements AuthModule {
491
602
  * subject/refresh token in the body, not as a Bearer header. We never log the
492
603
  * body: it carries a token.
493
604
  */
494
- private async postForm(
495
- path: string,
496
- body: URLSearchParams,
497
- label: string,
498
- ): Promise<TokenResponse> {
499
- const url = this.joinUrl(path);
605
+ private async postForm(path: string, body: URLSearchParams, label: string): Promise<TokenResponse> {
606
+ const url = this.joinUrl(path)
500
607
  const res = await this.httpTransport(url, {
501
608
  method: "POST",
502
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
609
+ headers: {"Content-Type": "application/x-www-form-urlencoded"},
503
610
  body: body.toString(),
504
- });
611
+ })
505
612
 
506
613
  if (!res.ok) {
507
614
  // The body may carry an RFC `{ error, error_description }`, but we keep the
508
615
  // thrown detail to the status + label so no token field can leak into a
509
616
  // message a host might surface. The caller maps this to re-auth.
510
- this.logger.warn("auth token request failed", { label, status: res.status });
511
- throw new AuthExpiredError(`${label} request failed with status ${res.status}`);
617
+ this.logger.warn("auth token request failed", {label, status: res.status})
618
+ throw new AuthExpiredError(`${label} request failed with status ${res.status}`)
512
619
  }
513
620
 
514
- return (await res.json()) as TokenResponse;
621
+ return (await res.json()) as TokenResponse
515
622
  }
516
623
 
517
624
  /**
@@ -522,11 +629,11 @@ export class Auth implements AuthModule {
522
629
  */
523
630
  private joinUrl(path: string): string {
524
631
  if (!this.baseUrl) {
525
- throw new AuthExpiredError("core endpoint is not configured; this API is unavailable in runtime-only mode");
632
+ throw new AuthExpiredError("core endpoint is not configured; this API is unavailable in runtime-only mode")
526
633
  }
527
- const base = this.baseUrl.replace(/\/+$/, "");
528
- const suffix = path.replace(/^\/+/, "");
529
- return `${base}/${suffix}`;
634
+ const base = this.baseUrl.replace(/\/+$/, "")
635
+ const suffix = path.replace(/^\/+/, "")
636
+ return `${base}/${suffix}`
530
637
  }
531
638
 
532
639
  /**
@@ -537,17 +644,23 @@ export class Auth implements AuthModule {
537
644
  * exchange/refresh so a later failure can notify again.
538
645
  */
539
646
  private fireExpired(): void {
540
- if (this.expiredFired) return;
541
- this.expiredFired = true;
647
+ if (this.expiredFired) return
648
+ this.expiredFired = true
542
649
  for (const handler of this.expiredHandlers) {
543
650
  try {
544
- handler();
651
+ handler()
545
652
  } catch (err) {
546
653
  // A host handler throwing must not stop the others from running.
547
654
  this.logger.error("onExpired handler threw", {
548
655
  error: err instanceof Error ? err.message : String(err),
549
- });
656
+ })
550
657
  }
551
658
  }
552
659
  }
660
+
661
+ private assertSessionActive(generation = this.sessionGeneration): void {
662
+ if (this.sessionCleared || generation !== this.sessionGeneration) {
663
+ throw new AuthExpiredError("auth session was cleared")
664
+ }
665
+ }
553
666
  }