@glassly/cloud-client 0.1.0-dev.0 → 0.1.0-dev.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glassly/cloud-client",
3
- "version": "0.1.0-dev.0",
3
+ "version": "0.1.0-dev.22",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "exports": {
@@ -12,7 +12,7 @@
12
12
  "test": "bun test"
13
13
  },
14
14
  "dependencies": {
15
- "@glassly/cloud-protocol": "^0.1.0-dev.0",
15
+ "@glassly/cloud-protocol": "0.1.0-dev.22",
16
16
  "tweetnacl": "^1.0.3"
17
17
  },
18
18
  "devDependencies": {
package/src/errors.ts CHANGED
@@ -52,3 +52,20 @@ export class AuthExpiredError extends CloudClientError {
52
52
  Object.setPrototypeOf(this, new.target.prototype);
53
53
  }
54
54
  }
55
+
56
+ /**
57
+ * A token endpoint DEFINITIVELY rejected the presented credential — the RFC
58
+ * `invalid_grant` shape or an authorization-level 401. Only this proves a
59
+ * stored token is dead. Network failures, 5xx, 429, and other 400 shapes are
60
+ * transient: throwing anything else must never cost the stored session
61
+ * (clearing tokens on a flaky cold boot is how users get logged out by an
62
+ * app update). Subclasses `AuthExpiredError` so hosts catching the broad
63
+ * "re-auth required" case keep working.
64
+ */
65
+ export class AuthRejectedError extends AuthExpiredError {
66
+ constructor(message = "Credential rejected by the token endpoint; re-auth required") {
67
+ super(message);
68
+ this.name = "AuthRejectedError";
69
+ Object.setPrototypeOf(this, new.target.prototype);
70
+ }
71
+ }
package/src/index.ts CHANGED
@@ -35,7 +35,7 @@ export type {
35
35
  export type { CloudClientTimers } from "./timers";
36
36
 
37
37
  // Local error types a host can branch on with `instanceof`.
38
- export { CloudClientError, HttpError, AuthExpiredError } from "./errors";
38
+ export { CloudClientError, HttpError, AuthExpiredError, AuthRejectedError } from "./errors";
39
39
 
40
40
  // The logging hook a host can implement to route library logs.
41
41
  export { noopLogger } from "./logger";
@@ -14,10 +14,14 @@
14
14
  * - Exchange, refresh, and each per-miniapp mint are single-flighted, so a
15
15
  * reconnect storm cannot fire a burst of competing requests (and a rotated
16
16
  * refresh token cannot be invalidated out from under a concurrent caller).
17
- * - If a refresh fails and the host can fetch a fresh subject token on demand,
18
- * we clear the dead refresh token and exchange once. If no fresh subject is
19
- * available (or exchange also fails), `onExpired` fires once and the host
17
+ * - If a refresh is DEFINITIVELY rejected (`invalid_grant` / 401) and the
18
+ * host can fetch a fresh subject token on demand, we clear the dead
19
+ * refresh token and exchange once. If no fresh subject is available (or
20
+ * the exchange is itself rejected), `onExpired` fires once and the host
20
21
  * re-authenticates. We do not retry forever against a dead refresh token.
22
+ * Transient failures — network errors, 5xx, 429 — never clear stored
23
+ * tokens and never fire `onExpired`: a flaky cold boot (first launch
24
+ * after an app update) must not cost a healthy session.
21
25
  *
22
26
  * Security: the access token is never written to storage, never given to a
23
27
  * miniapp, and no token is ever logged.
@@ -34,7 +38,7 @@ import type {
34
38
  import type { HttpClient } from "../../http";
35
39
  import type { Logger } from "../../logger";
36
40
  import type { HttpTransport } from "../../transports";
37
- import { AuthExpiredError } from "../../errors";
41
+ import { AuthExpiredError, AuthRejectedError, HttpError } from "../../errors";
38
42
  import { decodeClaims } from "./jwt";
39
43
  import { TokenStore } from "./token-store";
40
44
 
@@ -329,12 +333,23 @@ export class Auth implements AuthModule {
329
333
  try {
330
334
  return await this.refresh(refreshToken, { deferExpired: this.canExchangeFreshSubject() });
331
335
  } catch (err) {
332
- if (this.canExchangeFreshSubject()) {
333
- this.logger.info("refresh failed; exchanging fresh subject token");
336
+ // Only a DEFINITIVE rejection warrants burning the session and
337
+ // minting a new one through the subject-token fallback. A transient
338
+ // failure (network down at cold boot, core mid-deploy) keeps the
339
+ // stored refresh token; the caller's next attempt retries with it.
340
+ if (err instanceof AuthExpiredError && this.canExchangeFreshSubject()) {
341
+ this.logger.info("refresh rejected; exchanging fresh subject token");
334
342
  try {
335
343
  return await this.exchange();
336
- } catch {
337
- this.fireExpired();
344
+ } catch (exchangeErr) {
345
+ // The exchange itself was rejected → credentials are truly dead.
346
+ // A transient exchange failure (getSubjectToken offline, POST
347
+ // failed) must NOT declare the session expired — the refresh
348
+ // token was already cleared, but the host's account session is
349
+ // intact and the next attempt re-exchanges.
350
+ if (exchangeErr instanceof AuthRejectedError) {
351
+ this.fireExpired();
352
+ }
338
353
  }
339
354
  }
340
355
  throw err;
@@ -389,11 +404,16 @@ export class Auth implements AuthModule {
389
404
  * Refresh near expiry: trade the stored refresh token for a new access token
390
405
  * and a rotated refresh token, saving both.
391
406
  *
392
- * On failure (the refresh token is dead or revoked) we clear stored state.
393
- * The caller either falls back to one fresh subject-token exchange (for
394
- * on-demand subject-token configs) or fires `onExpired` once and surfaces an
395
- * `AuthExpiredError`. We do not retry refresh forever: a dead refresh token
396
- * will not heal on its own, and retrying would loop.
407
+ * Only a DEFINITIVE rejection (`invalid_grant` / 401) clears stored state:
408
+ * the caller then either falls back to one fresh subject-token exchange
409
+ * (for on-demand subject-token configs) or fires `onExpired` once and
410
+ * surfaces an `AuthExpiredError`. We do not retry a rejected refresh: a
411
+ * dead refresh token will not heal on its own, and retrying would loop.
412
+ *
413
+ * A transient failure — network error, 5xx during a deploy, 429 — keeps
414
+ * the stored token and propagates as-is. Clearing on those is how a flaky
415
+ * cold start (the first launch after an app update, typically) used to log
416
+ * users out of a perfectly healthy session.
397
417
  */
398
418
  private async refresh(refreshToken: string, opts?: { deferExpired?: boolean }): Promise<string> {
399
419
  const body = new URLSearchParams({
@@ -404,14 +424,17 @@ export class Auth implements AuthModule {
404
424
  let tokens: TokenResponse;
405
425
  try {
406
426
  tokens = await this.postForm(REFRESH_PATH, body, "refresh");
407
- } catch {
408
- // The refresh token is unusable: drop it so we do not keep presenting a
409
- // known-bad token.
427
+ } catch (err) {
428
+ if (!(err instanceof AuthRejectedError)) {
429
+ throw err; // transient: the stored token may well still be good
430
+ }
431
+ // The refresh token is definitively unusable: drop it so we do not keep
432
+ // presenting a known-bad token.
410
433
  await this.store.clear();
411
434
  if (!opts?.deferExpired) {
412
435
  this.fireExpired();
413
436
  }
414
- throw new AuthExpiredError("token refresh failed; re-auth required");
437
+ throw new AuthExpiredError("token refresh rejected; re-auth required");
415
438
  }
416
439
 
417
440
  await this.store.save({
@@ -504,11 +527,23 @@ export class Auth implements AuthModule {
504
527
  });
505
528
 
506
529
  if (!res.ok) {
507
- // The body may carry an RFC `{ error, error_description }`, but we keep the
508
- // thrown detail to the status + label so no token field can leak into a
509
- // 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}`);
530
+ // The body may carry an RFC `{ error, error_description }`; read only the
531
+ // machine code so no token field can leak into a message a host might
532
+ // surface. Only `invalid_grant` (or an authorization-level 401) proves
533
+ // the presented credential is dead anything else (5xx during a deploy,
534
+ // 429, a malformed-request 400) is transient and must not cost the
535
+ // stored session.
536
+ let code: string | undefined;
537
+ try {
538
+ code = ((await res.json()) as { error?: string })?.error;
539
+ } catch {
540
+ // Non-JSON body (proxy error page); status alone decides.
541
+ }
542
+ this.logger.warn("auth token request failed", { label, status: res.status, code });
543
+ if (res.status === 401 || (res.status === 400 && code === "invalid_grant")) {
544
+ throw new AuthRejectedError(`${label} rejected with status ${res.status}`);
545
+ }
546
+ throw new HttpError(`${label} request failed with status ${res.status}`, res.status, code);
512
547
  }
513
548
 
514
549
  return (await res.json()) as TokenResponse;