@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.
- package/README.md +384 -181
- package/dist/browser.d.mts +1 -1
- package/dist/browser.d.ts +1 -1
- package/dist/browser.js +37 -2
- package/dist/browser.mjs +1 -1
- package/dist/{chunk-5HF3OBNO.mjs → chunk-JQRTY5MY.mjs} +29 -5
- package/dist/{chunk-YDO2RDWQ.mjs → chunk-S3M2IXCE.mjs} +37 -2
- package/dist/express.js +29 -5
- package/dist/express.mjs +1 -1
- package/dist/fastify.js +29 -5
- package/dist/fastify.mjs +1 -1
- package/dist/hono.js +29 -5
- package/dist/hono.mjs +1 -1
- package/dist/next.js +29 -5
- package/dist/next.mjs +1 -1
- package/dist/react.d.mts +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +37 -2
- package/dist/react.mjs +1 -1
- package/dist/server/handlers.d.mts +29 -2
- package/dist/server/handlers.d.ts +29 -2
- package/dist/server/handlers.js +29 -5
- package/dist/server/handlers.mjs +1 -1
- package/dist/server.js +29 -5
- package/dist/server.mjs +1 -1
- package/dist/{signIn-C8f6qVjD.d.mts → signIn-CEMdUAwd.d.mts} +22 -0
- package/dist/{signIn-Cy2lbEXb.d.ts → signIn-VRNzlNyG.d.ts} +22 -0
- package/docs/BROWSER_SESSION_MIGRATION.md +34 -0
- package/package.json +1 -1
|
@@ -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>;
|
package/dist/server/handlers.js
CHANGED
|
@@ -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
|
|
218
|
+
status,
|
|
195
219
|
body: {
|
|
196
220
|
success: false,
|
|
197
221
|
error: {
|
|
198
|
-
code:
|
|
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 = [
|
package/dist/server/handlers.mjs
CHANGED
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
|
|
2149
|
+
status,
|
|
2126
2150
|
body: {
|
|
2127
2151
|
success: false,
|
|
2128
2152
|
error: {
|
|
2129
|
-
code:
|
|
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
|
@@ -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