@iqauth/sdk 2.0.2 → 2.0.4

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.
@@ -63,14 +63,33 @@ interface IQAuthHelperConfig {
63
63
  logoutPath?: string;
64
64
  /** Optional fetch implementation override. */
65
65
  fetchImpl?: typeof fetch;
66
+ /**
67
+ * Policy for clearing the access + refresh cookies when `/refresh` fails.
68
+ *
69
+ * - `"terminal-only"` (default, recommended): only clear cookies when the
70
+ * issuer indicates the session is unrecoverable
71
+ * (`TOKEN_REVOKED`, `SESSION_REVOKED`, `INVALID_GRANT`,
72
+ * `USER_DEACTIVATED`, or HTTP 410 Gone). Transient failures
73
+ * (`TOKEN_INVALID` from a rotated-out token, `TOKEN_EXPIRED`, network
74
+ * errors, 5xx) leave cookies intact so the next legitimate request can
75
+ * either succeed against a still-valid access cookie or be redirected
76
+ * cleanly to sign-in by the middleware. Fixes the multi-tab /
77
+ * proactive-refresh race that previously silently signed users out.
78
+ * - `"always"`: pre-2.0.3 behavior — wipe both cookies on any non-2xx.
79
+ * Use only if you have an external reason to depend on the old semantics.
80
+ * - `"never"`: leave cookies untouched on every failure path. Suitable for
81
+ * apps that manage cookie lifecycle entirely outside the SDK helpers.
82
+ */
83
+ clearCookiesOnRefreshFailure?: "terminal-only" | "always" | "never";
66
84
  }
67
- interface ResolvedConfig extends Required<Omit<IQAuthHelperConfig, "secretKey" | "cookieDomain" | "issuer" | "fetchImpl">> {
85
+ interface ResolvedConfig extends Required<Omit<IQAuthHelperConfig, "secretKey" | "cookieDomain" | "issuer" | "fetchImpl" | "clearCookiesOnRefreshFailure">> {
68
86
  secretKey?: string;
69
87
  cookieDomain?: string;
70
88
  issuer: string;
71
89
  fetchImpl: typeof fetch;
72
90
  appId: string;
73
91
  tenantId: string;
92
+ clearCookiesOnRefreshFailure: "terminal-only" | "always" | "never";
74
93
  }
75
94
  /**
76
95
  * Serialize a cookie directive to a Set-Cookie header value. Adapters that
@@ -84,7 +103,15 @@ declare function handleCallback(config: IQAuthHelperConfig, input: {
84
103
  codeVerifier?: string;
85
104
  redirectUri?: string;
86
105
  }): Promise<HandlerResponse>;
87
- /** POST /api/iqauth/refresh — rotate refresh + access cookies. */
106
+ /** POST /api/iqauth/refresh — rotate refresh + access cookies.
107
+ *
108
+ * Cookie-clearing policy is governed by `config.clearCookiesOnRefreshFailure`
109
+ * (default `"terminal-only"`). Prior to 2.0.3 this helper wiped both cookies
110
+ * on any non-2xx, which converted survivable refresh-token races (multi-tab,
111
+ * proactive-refresh timer, React StrictMode double-mount) into silent forced
112
+ * sign-outs. The default behavior now preserves cookies on transient failures
113
+ * and only clears them when the issuer signals the session is truly dead.
114
+ */
88
115
  declare function handleRefresh(config: IQAuthHelperConfig, input: {
89
116
  refreshToken?: string;
90
117
  }): Promise<HandlerResponse>;
@@ -57,6 +57,22 @@ function parsePublishableKey(raw) {
57
57
  }
58
58
 
59
59
  // src/server/handlers.ts
60
+ var TERMINAL_REFRESH_ERROR_CODES = /* @__PURE__ */ new Set([
61
+ "TOKEN_REVOKED",
62
+ "SESSION_REVOKED",
63
+ "INVALID_GRANT",
64
+ "invalid_grant",
65
+ "USER_DEACTIVATED",
66
+ "USER_DISABLED",
67
+ "TENANT_SUSPENDED"
68
+ ]);
69
+ function shouldClearCookiesOnFailure(policy, status, errorCode) {
70
+ if (policy === "always") return true;
71
+ if (policy === "never") return false;
72
+ if (status === 410) return true;
73
+ if (errorCode && TERMINAL_REFRESH_ERROR_CODES.has(errorCode)) return true;
74
+ return false;
75
+ }
60
76
  var ACCESS_TOKEN_TTL_SECONDS = 60 * 15;
61
77
  var REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30;
62
78
  function resolve(config) {
@@ -84,7 +100,8 @@ function resolve(config) {
84
100
  throw new Error("global fetch is unavailable; pass fetchImpl");
85
101
  })),
86
102
  appId: parsed.appId,
87
- tenantId: parsed.tenantId
103
+ tenantId: parsed.tenantId,
104
+ clearCookiesOnRefreshFailure: config.clearCookiesOnRefreshFailure ?? "terminal-only"
88
105
  };
89
106
  }
90
107
  function makeCookie(cfg, name, value, maxAge, httpOnly = true) {
@@ -180,7 +197,7 @@ async function handleRefresh(config, input) {
180
197
  return {
181
198
  status: 401,
182
199
  body: { success: false, error: { code: "TOKEN_INVALID", message: "Missing refresh token" } },
183
- cookies: clearCookies(cfg)
200
+ cookies: cfg.clearCookiesOnRefreshFailure === "always" ? clearCookies(cfg) : []
184
201
  };
185
202
  }
186
203
  const res = await cfg.fetchImpl(`${cfg.issuer}${cfg.refreshPath}`, {
@@ -190,16 +207,23 @@ async function handleRefresh(config, input) {
190
207
  });
191
208
  const json = await res.json().catch(() => ({}));
192
209
  if (!res.ok || !json.success || !json.data?.accessToken) {
210
+ const status = res.status || 401;
211
+ const errorCode = json.error?.code || "TOKEN_INVALID";
212
+ const shouldClear = shouldClearCookiesOnFailure(
213
+ cfg.clearCookiesOnRefreshFailure,
214
+ status,
215
+ errorCode
216
+ );
193
217
  return {
194
- status: res.status || 401,
218
+ status,
195
219
  body: {
196
220
  success: false,
197
221
  error: {
198
- code: json.error?.code || "TOKEN_INVALID",
222
+ code: errorCode,
199
223
  message: json.error?.message || "Refresh failed"
200
224
  }
201
225
  },
202
- cookies: clearCookies(cfg)
226
+ cookies: shouldClear ? clearCookies(cfg) : []
203
227
  };
204
228
  }
205
229
  const cookies = [
@@ -3,7 +3,7 @@ import {
3
3
  handleRefresh,
4
4
  handleSignout,
5
5
  serializeCookie
6
- } from "../chunk-5HF3OBNO.mjs";
6
+ } from "../chunk-JQRTY5MY.mjs";
7
7
  import "../chunk-5WFR6Y33.mjs";
8
8
  import "../chunk-Y6FXYEAI.mjs";
9
9
  export {
package/dist/server.js CHANGED
@@ -1988,6 +1988,22 @@ function iqAuthMiddleware(clientOrOptions, options = {}) {
1988
1988
  }
1989
1989
 
1990
1990
  // src/server/handlers.ts
1991
+ var TERMINAL_REFRESH_ERROR_CODES = /* @__PURE__ */ new Set([
1992
+ "TOKEN_REVOKED",
1993
+ "SESSION_REVOKED",
1994
+ "INVALID_GRANT",
1995
+ "invalid_grant",
1996
+ "USER_DEACTIVATED",
1997
+ "USER_DISABLED",
1998
+ "TENANT_SUSPENDED"
1999
+ ]);
2000
+ function shouldClearCookiesOnFailure(policy, status, errorCode) {
2001
+ if (policy === "always") return true;
2002
+ if (policy === "never") return false;
2003
+ if (status === 410) return true;
2004
+ if (errorCode && TERMINAL_REFRESH_ERROR_CODES.has(errorCode)) return true;
2005
+ return false;
2006
+ }
1991
2007
  var ACCESS_TOKEN_TTL_SECONDS = 60 * 15;
1992
2008
  var REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30;
1993
2009
  function resolve(config) {
@@ -2015,7 +2031,8 @@ function resolve(config) {
2015
2031
  throw new Error("global fetch is unavailable; pass fetchImpl");
2016
2032
  })),
2017
2033
  appId: parsed.appId,
2018
- tenantId: parsed.tenantId
2034
+ tenantId: parsed.tenantId,
2035
+ clearCookiesOnRefreshFailure: config.clearCookiesOnRefreshFailure ?? "terminal-only"
2019
2036
  };
2020
2037
  }
2021
2038
  function makeCookie(cfg, name, value, maxAge, httpOnly = true) {
@@ -2111,7 +2128,7 @@ async function handleRefresh(config, input) {
2111
2128
  return {
2112
2129
  status: 401,
2113
2130
  body: { success: false, error: { code: "TOKEN_INVALID", message: "Missing refresh token" } },
2114
- cookies: clearCookies(cfg)
2131
+ cookies: cfg.clearCookiesOnRefreshFailure === "always" ? clearCookies(cfg) : []
2115
2132
  };
2116
2133
  }
2117
2134
  const res = await cfg.fetchImpl(`${cfg.issuer}${cfg.refreshPath}`, {
@@ -2121,16 +2138,23 @@ async function handleRefresh(config, input) {
2121
2138
  });
2122
2139
  const json = await res.json().catch(() => ({}));
2123
2140
  if (!res.ok || !json.success || !json.data?.accessToken) {
2141
+ const status = res.status || 401;
2142
+ const errorCode = json.error?.code || "TOKEN_INVALID";
2143
+ const shouldClear = shouldClearCookiesOnFailure(
2144
+ cfg.clearCookiesOnRefreshFailure,
2145
+ status,
2146
+ errorCode
2147
+ );
2124
2148
  return {
2125
- status: res.status || 401,
2149
+ status,
2126
2150
  body: {
2127
2151
  success: false,
2128
2152
  error: {
2129
- code: json.error?.code || "TOKEN_INVALID",
2153
+ code: errorCode,
2130
2154
  message: json.error?.message || "Refresh failed"
2131
2155
  }
2132
2156
  },
2133
- cookies: clearCookies(cfg)
2157
+ cookies: shouldClear ? clearCookies(cfg) : []
2134
2158
  };
2135
2159
  }
2136
2160
  const cookies = [
package/dist/server.mjs CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  handleRefresh,
9
9
  handleSignout,
10
10
  serializeCookie
11
- } from "./chunk-5HF3OBNO.mjs";
11
+ } from "./chunk-JQRTY5MY.mjs";
12
12
  import "./chunk-5WFR6Y33.mjs";
13
13
  import {
14
14
  IQAuthClient
@@ -80,6 +80,27 @@ interface SessionManagerOptions {
80
80
  * refresh to finish before falling back to its own. Defaults to 4000.
81
81
  */
82
82
  crossTabLockTimeoutMs?: number;
83
+ /**
84
+ * "Server-managed" session mode for apps where the backend (via the
85
+ * `@iqauth/sdk/{express,fastify,hono,next}` adapters) owns the HttpOnly
86
+ * `iqauth_at` + `iqauth_rt` cookies and is the sole authority on token
87
+ * rotation. When `true`:
88
+ *
89
+ * - `bootstrap()` learns session state with a single `GET userinfoPath`
90
+ * call (no token rotation, no race surface), instead of POSTing to
91
+ * `/refresh` with an empty body.
92
+ * - `scheduleProactiveRefresh()` is suppressed — the server middleware
93
+ * refreshes on real navigation, which is single-flight per request.
94
+ * - `tokenStore` defaults to a no-op store so JS never tries to read the
95
+ * (HttpOnly, invisible) refresh cookie.
96
+ *
97
+ * This is the recommended mode for any app with its own backend, especially
98
+ * confidential-client OIDC flows. It eliminates the multi-tab refresh-token
99
+ * rotation race that previously caused silent sign-outs.
100
+ *
101
+ * Defaults to `false` to preserve pre-2.0.3 behavior.
102
+ */
103
+ serverManagedSession?: boolean;
83
104
  }
84
105
  declare class SessionManager {
85
106
  private snapshot;
@@ -96,6 +117,7 @@ declare class SessionManager {
96
117
  private readonly proactiveRefresh;
97
118
  private readonly tokenStore;
98
119
  private readonly crossTabLockTimeoutMs;
120
+ private readonly serverManagedSession;
99
121
  private proactiveTimer;
100
122
  private bootstrapped;
101
123
  /** Pending refresh awaited by other tabs after a `refresh:claim` from us. */
@@ -80,6 +80,27 @@ interface SessionManagerOptions {
80
80
  * refresh to finish before falling back to its own. Defaults to 4000.
81
81
  */
82
82
  crossTabLockTimeoutMs?: number;
83
+ /**
84
+ * "Server-managed" session mode for apps where the backend (via the
85
+ * `@iqauth/sdk/{express,fastify,hono,next}` adapters) owns the HttpOnly
86
+ * `iqauth_at` + `iqauth_rt` cookies and is the sole authority on token
87
+ * rotation. When `true`:
88
+ *
89
+ * - `bootstrap()` learns session state with a single `GET userinfoPath`
90
+ * call (no token rotation, no race surface), instead of POSTing to
91
+ * `/refresh` with an empty body.
92
+ * - `scheduleProactiveRefresh()` is suppressed — the server middleware
93
+ * refreshes on real navigation, which is single-flight per request.
94
+ * - `tokenStore` defaults to a no-op store so JS never tries to read the
95
+ * (HttpOnly, invisible) refresh cookie.
96
+ *
97
+ * This is the recommended mode for any app with its own backend, especially
98
+ * confidential-client OIDC flows. It eliminates the multi-tab refresh-token
99
+ * rotation race that previously caused silent sign-outs.
100
+ *
101
+ * Defaults to `false` to preserve pre-2.0.3 behavior.
102
+ */
103
+ serverManagedSession?: boolean;
83
104
  }
84
105
  declare class SessionManager {
85
106
  private snapshot;
@@ -96,6 +117,7 @@ declare class SessionManager {
96
117
  private readonly proactiveRefresh;
97
118
  private readonly tokenStore;
98
119
  private readonly crossTabLockTimeoutMs;
120
+ private readonly serverManagedSession;
99
121
  private proactiveTimer;
100
122
  private bootstrapped;
101
123
  /** Pending refresh awaited by other tabs after a `refresh:claim` from us. */
@@ -2,6 +2,40 @@
2
2
 
3
3
  Use this guide when a first-party web app currently owns IQAuth tokens in browser code and needs to move to the supported session model.
4
4
 
5
+ ## Cookie-managed / confidential-client (recommended for any app with a backend)
6
+
7
+ If your app uses one of the framework adapters (`@iqauth/sdk/{express,fastify,hono,next}`) the backend owns the HttpOnly `iqauth_at` + `iqauth_rt` cookies and should be the **sole** authority on token rotation. In that setup the browser `SessionManager` must NOT also try to rotate refresh tokens — otherwise the proactive-refresh timer + multi-tab BroadcastChannel + React StrictMode double-mount can race against the backend's own refreshes, and one lost race used to wipe the cookies.
8
+
9
+ As of `@iqauth/sdk@2.0.3`, opt into "server-managed" mode with a single flag:
10
+
11
+ ```ts
12
+ import { SessionManager } from "@iqauth/sdk/browser";
13
+
14
+ const manager = new SessionManager({
15
+ publishableKey: process.env.NEXT_PUBLIC_IQAUTH_PUBLISHABLE_KEY!,
16
+ issuer: window.location.origin,
17
+ refreshPath: "/api/iqauth/refresh", // mounted by the framework adapter
18
+ serverManagedSession: true, // ← do this
19
+ });
20
+ ```
21
+
22
+ What `serverManagedSession: true` does:
23
+
24
+ - `bootstrap()` learns session state with a single read-only `GET /api/v1/auth/me` (override with `userinfoPath`) instead of POSTing to `/refresh`. No rotation, no race surface.
25
+ - `scheduleProactiveRefresh()` is suppressed. The server middleware refreshes on real navigation, which is single-flight per request and can't race itself.
26
+ - `tokenStore` defaults to a no-op store, so JS never tries to read the (HttpOnly, invisible) refresh cookie.
27
+
28
+ `<SignedIn>` / `<SignedOut>` / `useUser()` continue to work — they read from the userinfo bootstrap.
29
+
30
+ ### Cookie-clearing policy on `/refresh` failures
31
+
32
+ `@iqauth/sdk/server::handleRefresh` now defaults to `clearCookiesOnRefreshFailure: "terminal-only"`. Cookies are only wiped when the issuer signals the session is unrecoverable (`TOKEN_REVOKED`, `SESSION_REVOKED`, `INVALID_GRANT`, `USER_DEACTIVATED`, `TENANT_SUSPENDED`, or HTTP 410 Gone). Transient failures — `TOKEN_INVALID` from a rotated-out token, `TOKEN_EXPIRED`, network blips, 5xx — return 401 without touching cookies, so the next legitimate request can either succeed against a still-valid access cookie or get redirected to sign-in cleanly by the middleware.
33
+
34
+ Pre-2.0.3 behavior is available with `clearCookiesOnRefreshFailure: "always"`.
35
+
36
+ ---
37
+
38
+
5
39
  ## Target Model
6
40
 
7
41
  - browser talks to app backend
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iqauth/sdk",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
4
4
  "description": "TypeScript SDK for IQAuth — the canonical way for all IQ projects to integrate with IQAuthService",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",