@iqauth/sdk 2.6.4 → 2.8.1
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 +173 -1
- package/dist/browser-session.d.mts +4 -4
- package/dist/browser-session.d.ts +4 -4
- package/dist/browser-session.js +212 -46
- package/dist/browser-session.mjs +3 -3
- package/dist/browser.d.mts +5 -5
- package/dist/browser.d.ts +5 -5
- package/dist/browser.js +293 -34
- package/dist/browser.mjs +5 -5
- package/dist/{chunk-BVV54LPI.mjs → chunk-25SSYDIP.mjs} +10 -4
- package/dist/{chunk-XAWYUPMO.mjs → chunk-4V7FKOTG.mjs} +242 -22
- package/dist/{chunk-6I6RM4MN.mjs → chunk-6PJRLRB4.mjs} +33 -3
- package/dist/{chunk-SL3KRS4W.mjs → chunk-CIJORODR.mjs} +23 -1
- package/dist/{chunk-LIZYFXH7.mjs → chunk-DFWHSDYQ.mjs} +1 -1
- package/dist/chunk-GLXSIGVS.mjs +66 -0
- package/dist/{chunk-DJIBN2N7.mjs → chunk-GN37E64I.mjs} +29 -7
- package/dist/{chunk-WQWBJSSS.mjs → chunk-HVHNYPDC.mjs} +6 -6
- package/dist/chunk-JRDVUWAL.mjs +46 -0
- package/dist/{chunk-UNYDG2L4.mjs → chunk-NUO2I65G.mjs} +56 -23
- package/dist/{chunk-5T7GHBX6.mjs → chunk-TLET552H.mjs} +36 -0
- package/dist/chunk-VYQ3ETCK.mjs +244 -0
- package/dist/{chunk-3JULWS6F.mjs → chunk-WCELYTJ3.mjs} +3 -3
- package/dist/chunk-WHT6WKTY.mjs +3180 -0
- package/dist/{chunk-MKKZULZR.mjs → chunk-WIFG74IK.mjs} +1 -1
- package/dist/chunk-WSH4SW7F.mjs +490 -0
- package/dist/{chunk-W3F4JYGP.mjs → chunk-ZLJPABB7.mjs} +139 -23
- package/dist/cli/index.js +2 -2
- package/dist/cli/index.mjs +2 -2
- package/dist/{client-BNQe3AgF.d.ts → client-D8L-PaWr.d.mts} +59 -6
- package/dist/{client-kYlJFgPv.d.mts → client-DkPL0EPZ.d.ts} +59 -6
- package/dist/{doctor-YYNHNMLD.mjs → doctor-JAFXWU3X.mjs} +2 -2
- package/dist/errors-Jl1Jtm-6.d.mts +107 -0
- package/dist/errors-Jl1Jtm-6.d.ts +107 -0
- package/dist/{express-CHpfa7D_.d.ts → express-Budysq4h.d.ts} +2 -2
- package/dist/{express-B6_1vBYZ.d.mts → express-DDTA3qV1.d.mts} +2 -2
- package/dist/express.d.mts +7 -6
- package/dist/express.d.ts +7 -6
- package/dist/express.js +563 -85
- package/dist/express.mjs +73 -34
- package/dist/fastify.d.mts +10 -0
- package/dist/fastify.d.ts +10 -0
- package/dist/fastify.js +589 -65
- package/dist/fastify.mjs +101 -11
- package/dist/hono.d.mts +10 -0
- package/dist/hono.d.ts +10 -0
- package/dist/hono.js +566 -65
- package/dist/hono.mjs +78 -11
- package/dist/index-Cko-d5po.d.mts +1848 -0
- package/dist/index-RNqwEcmY.d.ts +1848 -0
- package/dist/index.d.mts +56 -8
- package/dist/index.d.ts +56 -8
- package/dist/index.js +694 -75
- package/dist/index.mjs +30 -10
- package/dist/{keys-NLWFAOEM.mjs → keys-6Y776TG2.mjs} +2 -2
- package/dist/locales.d.mts +1 -1
- package/dist/locales.d.ts +1 -1
- package/dist/locales.js +36 -0
- package/dist/locales.mjs +1 -1
- package/dist/mobile.d.mts +77 -7
- package/dist/mobile.d.ts +77 -7
- package/dist/mobile.js +307 -46
- package/dist/mobile.mjs +98 -3
- package/dist/next.d.mts +10 -1
- package/dist/next.d.ts +10 -1
- package/dist/next.js +596 -205
- package/dist/next.mjs +83 -10
- package/dist/{provisioningBridge-88xjOS2n.d.mts → provisioningBridge-BXPMZCLe.d.ts} +30 -2
- package/dist/{provisioningBridge-DnTfzdZK.d.ts → provisioningBridge-IEycmsgb.d.mts} +30 -2
- package/dist/{publishableKey-BaR0HoAH.d.ts → publishableKey-f2kq-rKw.d.mts} +1 -1
- package/dist/{publishableKey-BaR0HoAH.d.mts → publishableKey-f2kq-rKw.d.ts} +1 -1
- package/dist/react-permissions.d.mts +52 -0
- package/dist/react-permissions.d.ts +52 -0
- package/dist/react-permissions.js +239 -0
- package/dist/react-permissions.mjs +98 -0
- package/dist/react.d.mts +9 -1624
- package/dist/react.d.ts +9 -1624
- package/dist/react.js +882 -73
- package/dist/react.mjs +71 -2631
- package/dist/{reverify-4UEJXUS6.mjs → reverify-C64QXKJO.mjs} +2 -2
- package/dist/server/handlers.d.mts +200 -4
- package/dist/server/handlers.d.ts +200 -4
- package/dist/server/handlers.js +530 -16
- package/dist/server/handlers.mjs +14 -3
- package/dist/server.d.mts +171 -8
- package/dist/server.d.ts +171 -8
- package/dist/server.js +579 -61
- package/dist/server.mjs +99 -12
- package/dist/service.d.mts +4 -4
- package/dist/service.d.ts +4 -4
- package/dist/service.js +212 -46
- package/dist/service.mjs +3 -3
- package/dist/{signIn-CiIBTJIh.d.mts → signIn-CReqfXsh.d.mts} +95 -3
- package/dist/{signIn-OCr88Zf8.d.ts → signIn-Cfa1GTpO.d.ts} +95 -3
- package/dist/{signIn-4OKLDEIH.mjs → signIn-SHBW6Z4T.mjs} +1 -1
- package/dist/test.mjs +3 -3
- package/dist/{tokens-DCyzzn8L.d.mts → tokens-9F6ETrzk.d.ts} +9 -2
- package/dist/{tokens-aHiGFr_E.d.ts → tokens-B06VtvUi.d.mts} +9 -2
- package/dist/{types-DZAflmmq.d.mts → types-Bn8O-OEd.d.mts} +164 -11
- package/dist/{types-DZAflmmq.d.ts → types-Bn8O-OEd.d.ts} +164 -11
- package/dist/{types-6bNdxesb.d.ts → types-DnU2LhXR.d.mts} +7 -1
- package/dist/{types-6bNdxesb.d.mts → types-DnU2LhXR.d.ts} +7 -1
- package/dist/webhooks.d.mts +113 -17
- package/dist/webhooks.d.ts +113 -17
- package/dist/webhooks.js +179 -15
- package/dist/webhooks.mjs +7 -1
- package/dist/ws.d.mts +2 -2
- package/dist/ws.d.ts +2 -2
- package/dist/ws.js +80 -30
- package/dist/ws.mjs +4 -4
- package/docs/error-handling.md +101 -0
- package/docs/guides/effective-permissions.md +171 -0
- package/docs/guides/invitations.md +65 -0
- package/package.json +19 -4
- package/dist/chunk-6TDJJER7.mjs +0 -217
- package/dist/chunk-UKZLOHZG.mjs +0 -83
- package/dist/errors-CDdl24MP.d.mts +0 -52
- package/dist/errors-CDdl24MP.d.ts +0 -52
|
@@ -3,15 +3,16 @@ import {
|
|
|
3
3
|
clearCookie,
|
|
4
4
|
getCookie,
|
|
5
5
|
setCookie
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-GN37E64I.mjs";
|
|
7
7
|
import {
|
|
8
8
|
assertPublishableKey
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-HVHNYPDC.mjs";
|
|
10
10
|
import {
|
|
11
11
|
IQAuthError
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-6PJRLRB4.mjs";
|
|
13
13
|
|
|
14
14
|
// src/browser/sessionManager.ts
|
|
15
|
+
var PROBE_WAIT_MS = 80;
|
|
15
16
|
var DEFAULT_REFRESH_PATH = "/api/v1/auth/refresh";
|
|
16
17
|
var DEFAULT_USERINFO_PATH = "/api/v1/auth/me";
|
|
17
18
|
async function readAuthErrorCode(res) {
|
|
@@ -49,7 +50,16 @@ function claimsToSessionUser(claims) {
|
|
|
49
50
|
tenantId: claims.tenantId,
|
|
50
51
|
vendorId: claims.vendorId,
|
|
51
52
|
roles: claims.roles ?? [],
|
|
52
|
-
entitlements: claims.entitlements ?? []
|
|
53
|
+
entitlements: claims.entitlements ?? [],
|
|
54
|
+
// SDK 2.7.0 (Task #124) — pass through identity claims when issued.
|
|
55
|
+
...claims.picture !== void 0 ? { picture: claims.picture } : {},
|
|
56
|
+
...claims.email_verified !== void 0 ? { emailVerified: claims.email_verified } : {},
|
|
57
|
+
...claims.given_name !== void 0 ? { givenName: claims.given_name } : {},
|
|
58
|
+
...claims.family_name !== void 0 ? { familyName: claims.family_name } : {},
|
|
59
|
+
...claims.locale !== void 0 ? { locale: claims.locale } : {},
|
|
60
|
+
// Task #171 — surface the active source/client scope when the token was
|
|
61
|
+
// minted scoped, so consumers reading useUser().user can branch on it.
|
|
62
|
+
...claims.scopeContext !== void 0 ? { scopeContext: claims.scopeContext } : {}
|
|
53
63
|
};
|
|
54
64
|
}
|
|
55
65
|
var EMPTY = {
|
|
@@ -73,11 +83,50 @@ var NO_OP_STORE = {
|
|
|
73
83
|
write: () => void 0,
|
|
74
84
|
clear: () => void 0
|
|
75
85
|
};
|
|
86
|
+
var IDEMPOTENCY_HEADER = "X-IQAuth-Idempotency";
|
|
87
|
+
function randomIdempotencyToken() {
|
|
88
|
+
if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
|
|
89
|
+
const bytes = new Uint8Array(16);
|
|
90
|
+
crypto.getRandomValues(bytes);
|
|
91
|
+
let out = "";
|
|
92
|
+
for (const b of bytes) out += b.toString(16).padStart(2, "0");
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
|
|
96
|
+
}
|
|
76
97
|
var SessionManager = class {
|
|
77
98
|
constructor(options) {
|
|
78
99
|
this.snapshot = { ...EMPTY };
|
|
79
100
|
this.listeners = /* @__PURE__ */ new Set();
|
|
80
101
|
this.refreshPromise = null;
|
|
102
|
+
/**
|
|
103
|
+
* Cancellation handle for the in-flight refresh, if any. `signOut()` (or a
|
|
104
|
+
* `session:signout` broadcast from another tab) calls `abort()` so the
|
|
105
|
+
* refresh response is dropped before it can write a fresh access cookie
|
|
106
|
+
* on top of the just-cleared session — the second root cause of "ghost
|
|
107
|
+
* signed-in" sessions after Sign Out.
|
|
108
|
+
*/
|
|
109
|
+
this.refreshAbort = null;
|
|
110
|
+
/**
|
|
111
|
+
* Set to `true` by `signOut()` / `signOutLocal()` for the lifetime of the
|
|
112
|
+
* call. Used as a safety belt: even if a refresh response arrives while
|
|
113
|
+
* `refreshAbort` was unable to interrupt the network call (e.g. the body
|
|
114
|
+
* was already streaming back), `runRefresh` checks this flag before
|
|
115
|
+
* mutating session state and bails out.
|
|
116
|
+
*/
|
|
117
|
+
this.signoutInProgress = false;
|
|
118
|
+
/**
|
|
119
|
+
* Per-session opaque idempotency token. Sent as `X-IQAuth-Idempotency` on
|
|
120
|
+
* every /refresh and /signout request the SDK makes through a framework
|
|
121
|
+
* adapter (Express/Fastify/Hono/Next), so the adapter's `SignoutRegistry`
|
|
122
|
+
* can collapse a refresh that lands moments after a signout — even when
|
|
123
|
+
* the two requests are routed to different server instances (multi-replica
|
|
124
|
+
* deployments).
|
|
125
|
+
*
|
|
126
|
+
* Generated lazily on first use, rotated on signout so the next session
|
|
127
|
+
* starts with a fresh token. Opaque random — never the raw refresh token.
|
|
128
|
+
*/
|
|
129
|
+
this.idempotencyToken = null;
|
|
81
130
|
this.channel = null;
|
|
82
131
|
this.proactiveTimer = null;
|
|
83
132
|
this.bootstrapped = false;
|
|
@@ -85,6 +134,8 @@ var SessionManager = class {
|
|
|
85
134
|
this.remoteRefreshWaiters = [];
|
|
86
135
|
/** Active claims by other tabs (keyed by source tabId). */
|
|
87
136
|
this.foreignClaim = null;
|
|
137
|
+
/** Resolver for an in-flight cross-tab `session:probe`, set during bootstrap. */
|
|
138
|
+
this.probeResolver = null;
|
|
88
139
|
const parsed = assertPublishableKey(options.publishableKey, { context: "@iqauth/sdk/browser SessionManager" });
|
|
89
140
|
this.key = parsed;
|
|
90
141
|
const inferred = options.issuer ?? (parsed.iss.startsWith("http") ? parsed.iss : `https://${parsed.iss}`);
|
|
@@ -97,6 +148,8 @@ var SessionManager = class {
|
|
|
97
148
|
this.refreshCookieName = options.cookieNames?.refresh ?? REFRESH_COOKIE;
|
|
98
149
|
this.tokenStore = options.tokenStore ?? (this.serverManagedSession ? NO_OP_STORE : this.useCookies ? defaultCookieStore(this.refreshCookieName) : NO_OP_STORE);
|
|
99
150
|
this.crossTabLockTimeoutMs = options.crossTabLockTimeoutMs ?? 4e3;
|
|
151
|
+
this.debug = options.debug ?? false;
|
|
152
|
+
this.onTimingEvent = options.onTimingEvent ?? null;
|
|
100
153
|
this.fetchImpl = options.fetchImpl ?? (typeof fetch !== "undefined" ? fetch.bind(globalThis) : (() => {
|
|
101
154
|
throw new Error("global fetch is not available; pass fetchImpl");
|
|
102
155
|
}));
|
|
@@ -123,10 +176,35 @@ var SessionManager = class {
|
|
|
123
176
|
get issuerUrl() {
|
|
124
177
|
return this.issuer;
|
|
125
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* SDK 2.7.0 (Task #124) — The hosted IQAuth host derived from the
|
|
181
|
+
* publishable key's `iss` claim, normalized to URL form. This is what
|
|
182
|
+
* `<SignIn/>` and `buildSignInUrl` use to talk to the hosted UI; it
|
|
183
|
+
* deliberately ignores the `issuer` constructor override so a misrouted
|
|
184
|
+
* `issuer` (e.g. pointed at the consumer app's own domain) cannot break
|
|
185
|
+
* the hosted flow. Use {@link issuerUrl} for token / discovery endpoints.
|
|
186
|
+
*/
|
|
187
|
+
get hostedIssuerUrl() {
|
|
188
|
+
const iss = this.key.iss;
|
|
189
|
+
return (iss.startsWith("http") ? iss : `https://${iss}`).replace(/\/+$/, "");
|
|
190
|
+
}
|
|
126
191
|
/** Cookie name the SDK uses for the refresh token (overridable via `cookieNames.refresh`). */
|
|
127
192
|
get refreshCookie() {
|
|
128
193
|
return this.refreshCookieName;
|
|
129
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* Returns the current per-session idempotency token, generating one
|
|
197
|
+
* lazily on first use. Sent as the `X-IQAuth-Idempotency` header on
|
|
198
|
+
* /refresh and /signout requests so the framework adapter's
|
|
199
|
+
* `SignoutRegistry` can collapse a refresh-vs-signout race even across
|
|
200
|
+
* server instances.
|
|
201
|
+
*/
|
|
202
|
+
getIdempotencyToken() {
|
|
203
|
+
if (!this.idempotencyToken) {
|
|
204
|
+
this.idempotencyToken = randomIdempotencyToken();
|
|
205
|
+
}
|
|
206
|
+
return this.idempotencyToken;
|
|
207
|
+
}
|
|
130
208
|
getSnapshot() {
|
|
131
209
|
return this.snapshot;
|
|
132
210
|
}
|
|
@@ -140,9 +218,44 @@ var SessionManager = class {
|
|
|
140
218
|
* One-time bootstrap: warm the session from the refresh cookie if present.
|
|
141
219
|
* Safe to call multiple times.
|
|
142
220
|
*/
|
|
221
|
+
/**
|
|
222
|
+
* Task #126: Public timing-event emitter. Used by the browser sign-in
|
|
223
|
+
* helpers (redirectToSignIn / handleAuthCallback) to surface signIn-phase
|
|
224
|
+
* timings through the same `debug` + `onTimingEvent` channel as
|
|
225
|
+
* bootstrap/refresh. Safe to call from anywhere — internal callers
|
|
226
|
+
* pre-compute durationMs.
|
|
227
|
+
*/
|
|
228
|
+
recordTiming(phase, durationMs, ok, code) {
|
|
229
|
+
this.emitTiming(phase, durationMs, ok, code);
|
|
230
|
+
}
|
|
231
|
+
/** Task #126: emit a session timing event to debug log + onTimingEvent hook. */
|
|
232
|
+
emitTiming(phase, durationMs, ok, code) {
|
|
233
|
+
if (this.debug) {
|
|
234
|
+
try {
|
|
235
|
+
console.debug("[iqauth_session]", { phase, durationMs, ok, code });
|
|
236
|
+
} catch {
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (this.onTimingEvent) {
|
|
240
|
+
try {
|
|
241
|
+
this.onTimingEvent({ phase, durationMs, ok, code });
|
|
242
|
+
} catch {
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
143
246
|
async bootstrap() {
|
|
144
247
|
if (this.bootstrapped) return;
|
|
145
248
|
this.bootstrapped = true;
|
|
249
|
+
const t0 = Date.now();
|
|
250
|
+
try {
|
|
251
|
+
await this.bootstrapInner();
|
|
252
|
+
this.emitTiming("bootstrap", Date.now() - t0, this.snapshot.status === "authenticated");
|
|
253
|
+
} catch (err) {
|
|
254
|
+
this.emitTiming("bootstrap", Date.now() - t0, false, err instanceof Error ? err.message : "ERROR");
|
|
255
|
+
throw err;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
async bootstrapInner() {
|
|
146
259
|
if (this.serverManagedSession) {
|
|
147
260
|
try {
|
|
148
261
|
const res = await this.fetchImpl(`${this.issuer}${this.userinfoPath}`, {
|
|
@@ -177,6 +290,15 @@ var SessionManager = class {
|
|
|
177
290
|
return;
|
|
178
291
|
}
|
|
179
292
|
}
|
|
293
|
+
const peerSnapshot = await this.probePeers();
|
|
294
|
+
if (peerSnapshot && peerSnapshot.status === "authenticated") {
|
|
295
|
+
this.update({
|
|
296
|
+
...peerSnapshot,
|
|
297
|
+
version: Math.max(this.snapshot.version, peerSnapshot.version) + 1
|
|
298
|
+
});
|
|
299
|
+
this.scheduleProactiveRefresh();
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
180
302
|
const stored = await Promise.resolve(this.tokenStore.read());
|
|
181
303
|
if (!stored) {
|
|
182
304
|
this.setStatus("unauthenticated");
|
|
@@ -185,6 +307,22 @@ var SessionManager = class {
|
|
|
185
307
|
const ok = await this.refresh();
|
|
186
308
|
if (!ok) this.setStatus("unauthenticated");
|
|
187
309
|
}
|
|
310
|
+
probePeers() {
|
|
311
|
+
if (!this.channel) return Promise.resolve(null);
|
|
312
|
+
return new Promise((resolve) => {
|
|
313
|
+
let settled = false;
|
|
314
|
+
const finish = (snap) => {
|
|
315
|
+
if (settled) return;
|
|
316
|
+
settled = true;
|
|
317
|
+
this.probeResolver = null;
|
|
318
|
+
clearTimeout(timer);
|
|
319
|
+
resolve(snap);
|
|
320
|
+
};
|
|
321
|
+
this.probeResolver = (snap) => finish(snap);
|
|
322
|
+
const timer = setTimeout(() => finish(null), PROBE_WAIT_MS);
|
|
323
|
+
this.broadcastEnvelope({ type: "session:probe", source: this.tabId, ts: Date.now() });
|
|
324
|
+
});
|
|
325
|
+
}
|
|
188
326
|
/**
|
|
189
327
|
* Single-flight token refresh, coordinated across tabs via BroadcastChannel.
|
|
190
328
|
*
|
|
@@ -198,30 +336,48 @@ var SessionManager = class {
|
|
|
198
336
|
*/
|
|
199
337
|
refresh() {
|
|
200
338
|
if (this.refreshPromise) return this.refreshPromise;
|
|
201
|
-
|
|
339
|
+
const t0 = Date.now();
|
|
340
|
+
this.refreshPromise = this.runRefresh().then((ok) => {
|
|
341
|
+
this.emitTiming("refresh", Date.now() - t0, ok, ok ? void 0 : this.snapshot.error?.code ?? "REFRESH_FAILED");
|
|
342
|
+
return ok;
|
|
343
|
+
}).finally(() => {
|
|
202
344
|
this.refreshPromise = null;
|
|
203
345
|
});
|
|
204
346
|
return this.refreshPromise;
|
|
205
347
|
}
|
|
206
348
|
async runRefresh() {
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
this.broadcastEnvelope({ type: "refresh:claim", source: myClaim.source, ts: myClaim.ts });
|
|
210
|
-
await new Promise((r) => setTimeout(r, 25));
|
|
211
|
-
const foreign = this.foreignClaim;
|
|
212
|
-
if (foreign && this.claimWins(foreign, myClaim)) {
|
|
213
|
-
return this.waitForForeignRefresh();
|
|
214
|
-
}
|
|
215
|
-
}
|
|
349
|
+
const abort = new AbortController();
|
|
350
|
+
this.refreshAbort = abort;
|
|
216
351
|
try {
|
|
352
|
+
const myClaim = { source: this.tabId, ts: Date.now() };
|
|
353
|
+
if (this.channel) {
|
|
354
|
+
this.broadcastEnvelope({ type: "refresh:claim", source: myClaim.source, ts: myClaim.ts });
|
|
355
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
356
|
+
if (abort.signal.aborted || this.signoutInProgress) {
|
|
357
|
+
this.broadcastEnvelope({ type: "refresh:done", source: this.tabId, ts: Date.now(), ok: false });
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
const foreign = this.foreignClaim;
|
|
361
|
+
if (foreign && this.claimWins(foreign, myClaim)) {
|
|
362
|
+
return this.waitForForeignRefresh();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
217
365
|
const refreshToken = await Promise.resolve(this.tokenStore.read());
|
|
218
366
|
const res = await this.fetchImpl(`${this.issuer}${this.refreshPath}`, {
|
|
219
367
|
method: "POST",
|
|
220
368
|
credentials: "include",
|
|
221
|
-
headers: {
|
|
222
|
-
|
|
369
|
+
headers: {
|
|
370
|
+
"Content-Type": "application/json",
|
|
371
|
+
[IDEMPOTENCY_HEADER]: this.getIdempotencyToken()
|
|
372
|
+
},
|
|
373
|
+
body: JSON.stringify(refreshToken ? { refreshToken } : {}),
|
|
374
|
+
signal: abort.signal
|
|
223
375
|
});
|
|
224
376
|
const body = await res.json().catch(() => ({}));
|
|
377
|
+
if (this.signoutInProgress || abort.signal.aborted) {
|
|
378
|
+
this.broadcastEnvelope({ type: "refresh:done", source: this.tabId, ts: Date.now(), ok: false });
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
225
381
|
const data = body.data;
|
|
226
382
|
if (!res.ok || !body.success || !data?.accessToken) {
|
|
227
383
|
const err = body.error;
|
|
@@ -240,14 +396,18 @@ var SessionManager = class {
|
|
|
240
396
|
this.broadcastEnvelope({ type: "refresh:done", source: this.tabId, ts: Date.now(), ok: true });
|
|
241
397
|
return true;
|
|
242
398
|
} catch (err) {
|
|
243
|
-
this.
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
399
|
+
const aborted = err?.name === "AbortError" || abort.signal.aborted || this.signoutInProgress;
|
|
400
|
+
if (!aborted) {
|
|
401
|
+
this.setError({
|
|
402
|
+
code: "NETWORK_ERROR",
|
|
403
|
+
message: err instanceof Error ? err.message : "Refresh request failed"
|
|
404
|
+
});
|
|
405
|
+
}
|
|
247
406
|
this.broadcastEnvelope({ type: "refresh:done", source: this.tabId, ts: Date.now(), ok: false });
|
|
248
407
|
return false;
|
|
249
408
|
} finally {
|
|
250
409
|
this.foreignClaim = null;
|
|
410
|
+
if (this.refreshAbort === abort) this.refreshAbort = null;
|
|
251
411
|
}
|
|
252
412
|
}
|
|
253
413
|
claimWins(foreign, mine) {
|
|
@@ -274,10 +434,27 @@ var SessionManager = class {
|
|
|
274
434
|
* session and notify subscribers and other tabs.
|
|
275
435
|
*/
|
|
276
436
|
applyAccessToken(accessToken, refreshToken) {
|
|
437
|
+
this.adoptAccessToken(accessToken, { refreshToken });
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Task #197 — Adopt an access token that the server has already minted
|
|
441
|
+
* for us (e.g. from `POST /api/v1/auth/switch-scope`) without contacting
|
|
442
|
+
* the issuer. Swaps the in-memory token, re-decodes claims, bumps
|
|
443
|
+
* `version`, schedules proactive refresh, and broadcasts a
|
|
444
|
+
* `session:update` to peer tabs.
|
|
445
|
+
*
|
|
446
|
+
* This is the safe path for any server endpoint that returns a fresh
|
|
447
|
+
* access token in its JSON body: we want the new claims (scope, roles,
|
|
448
|
+
* etc.) to take effect immediately, even if the refresh-cookie round-trip
|
|
449
|
+
* would have failed (network blip, rate limit, signout race). When the
|
|
450
|
+
* server also rotated the refresh token, pass it via
|
|
451
|
+
* `opts.refreshToken` so the cookie stays aligned.
|
|
452
|
+
*/
|
|
453
|
+
adoptAccessToken(accessToken, opts) {
|
|
277
454
|
const claims = decodeClaims(accessToken);
|
|
278
455
|
const user = claimsToSessionUser(claims);
|
|
279
|
-
if (refreshToken) {
|
|
280
|
-
void Promise.resolve(this.tokenStore.write(refreshToken, { claims }));
|
|
456
|
+
if (opts?.refreshToken) {
|
|
457
|
+
void Promise.resolve(this.tokenStore.write(opts.refreshToken, { claims }));
|
|
281
458
|
}
|
|
282
459
|
this.update({
|
|
283
460
|
status: user ? "authenticated" : "unauthenticated",
|
|
@@ -355,6 +532,14 @@ var SessionManager = class {
|
|
|
355
532
|
* the server-side logout request.
|
|
356
533
|
*/
|
|
357
534
|
signOutLocal(status = "unauthenticated") {
|
|
535
|
+
this.signoutInProgress = true;
|
|
536
|
+
if (this.refreshAbort) {
|
|
537
|
+
try {
|
|
538
|
+
this.refreshAbort.abort();
|
|
539
|
+
} catch {
|
|
540
|
+
}
|
|
541
|
+
this.refreshAbort = null;
|
|
542
|
+
}
|
|
358
543
|
void Promise.resolve(this.tokenStore.clear());
|
|
359
544
|
if (this.proactiveTimer) {
|
|
360
545
|
clearTimeout(this.proactiveTimer);
|
|
@@ -369,7 +554,12 @@ var SessionManager = class {
|
|
|
369
554
|
error: null,
|
|
370
555
|
version: this.snapshot.version + 1
|
|
371
556
|
});
|
|
557
|
+
this.broadcastEnvelope({ type: "refresh:abort", source: this.tabId, ts: Date.now() });
|
|
372
558
|
this.broadcast("session:signout");
|
|
559
|
+
this.idempotencyToken = null;
|
|
560
|
+
setTimeout(() => {
|
|
561
|
+
this.signoutInProgress = false;
|
|
562
|
+
}, 0);
|
|
373
563
|
}
|
|
374
564
|
/**
|
|
375
565
|
* Replace the refresh-token store at runtime. Used by the F22
|
|
@@ -433,6 +623,12 @@ var SessionManager = class {
|
|
|
433
623
|
}
|
|
434
624
|
onBroadcast(env) {
|
|
435
625
|
if (!env || env.source === this.tabId) return;
|
|
626
|
+
if (env.type === "session:probe") {
|
|
627
|
+
if (this.snapshot.status === "authenticated") {
|
|
628
|
+
this.broadcast("session:update");
|
|
629
|
+
}
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
436
632
|
if (env.type === "refresh:claim") {
|
|
437
633
|
this.foreignClaim = { source: env.source, ts: env.ts };
|
|
438
634
|
return;
|
|
@@ -445,6 +641,24 @@ var SessionManager = class {
|
|
|
445
641
|
this.foreignClaim = null;
|
|
446
642
|
return;
|
|
447
643
|
}
|
|
644
|
+
if (env.type === "refresh:abort") {
|
|
645
|
+
this.signoutInProgress = true;
|
|
646
|
+
if (this.refreshAbort) {
|
|
647
|
+
try {
|
|
648
|
+
this.refreshAbort.abort();
|
|
649
|
+
} catch {
|
|
650
|
+
}
|
|
651
|
+
this.refreshAbort = null;
|
|
652
|
+
}
|
|
653
|
+
const waiters = this.remoteRefreshWaiters;
|
|
654
|
+
this.remoteRefreshWaiters = [];
|
|
655
|
+
for (const w of waiters) w(false);
|
|
656
|
+
this.foreignClaim = null;
|
|
657
|
+
setTimeout(() => {
|
|
658
|
+
this.signoutInProgress = false;
|
|
659
|
+
}, 0);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
448
662
|
if (env.type === "session:signout") {
|
|
449
663
|
this.update({
|
|
450
664
|
status: "unauthenticated",
|
|
@@ -458,6 +672,12 @@ var SessionManager = class {
|
|
|
458
672
|
return;
|
|
459
673
|
}
|
|
460
674
|
if ((env.type === "session:update" || env.type === "session:refresh") && env.payload) {
|
|
675
|
+
if (this.probeResolver && env.payload.status === "authenticated") {
|
|
676
|
+
const r = this.probeResolver;
|
|
677
|
+
this.probeResolver = null;
|
|
678
|
+
r(env.payload);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
461
681
|
this.update({
|
|
462
682
|
...env.payload,
|
|
463
683
|
version: Math.max(this.snapshot.version, env.payload.version) + 1
|
|
@@ -1,11 +1,40 @@
|
|
|
1
1
|
// src/errors.ts
|
|
2
|
-
var
|
|
3
|
-
|
|
2
|
+
var IQ_AUTH_ERROR_CODES = [
|
|
3
|
+
"token_expired",
|
|
4
|
+
"token_invalid",
|
|
5
|
+
"jwks_unavailable",
|
|
6
|
+
"jwks_fetch_failed",
|
|
7
|
+
"rate_limited",
|
|
8
|
+
"network",
|
|
9
|
+
"config_invalid",
|
|
10
|
+
"app_not_found",
|
|
11
|
+
"permission_denied",
|
|
12
|
+
"unknown"
|
|
13
|
+
];
|
|
14
|
+
var IQAuthError = class _IQAuthError extends Error {
|
|
15
|
+
constructor(code, message, status, cause) {
|
|
4
16
|
super(message);
|
|
5
17
|
this.name = "IQAuthError";
|
|
6
18
|
this.code = code;
|
|
7
19
|
this.status = status;
|
|
8
|
-
this.
|
|
20
|
+
this.cause = cause;
|
|
21
|
+
this.raw = cause;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Type guard: true when `value` is an `IQAuthError`. Useful for adapters
|
|
25
|
+
* that round-trip errors through `unknown` (e.g. fastify's `setErrorHandler`).
|
|
26
|
+
*/
|
|
27
|
+
static isIQAuthError(value) {
|
|
28
|
+
return value instanceof _IQAuthError || typeof value === "object" && value !== null && value.name === "IQAuthError" && typeof value.code === "string";
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Type-narrowed code check. Lets callers write
|
|
32
|
+
* `if (err.is("token_expired")) …` with full IntelliSense for the typed
|
|
33
|
+
* taxonomy without losing the ability to handle server codes via
|
|
34
|
+
* `err.code === "TOKEN_REVOKED"`.
|
|
35
|
+
*/
|
|
36
|
+
is(code) {
|
|
37
|
+
return this.code === code;
|
|
9
38
|
}
|
|
10
39
|
};
|
|
11
40
|
var ErrorCodes = {
|
|
@@ -46,6 +75,7 @@ var ErrorCodes = {
|
|
|
46
75
|
};
|
|
47
76
|
|
|
48
77
|
export {
|
|
78
|
+
IQ_AUTH_ERROR_CODES,
|
|
49
79
|
IQAuthError,
|
|
50
80
|
ErrorCodes
|
|
51
81
|
};
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
// src/server/provisioningBridge.ts
|
|
2
|
+
var ProvisioningError = class extends Error {
|
|
3
|
+
constructor(code, message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "ProvisioningError";
|
|
6
|
+
this.code = code;
|
|
7
|
+
}
|
|
8
|
+
};
|
|
2
9
|
function defaultIsUniqueViolation(err) {
|
|
3
10
|
if (!err || typeof err !== "object") return false;
|
|
4
11
|
const e = err;
|
|
@@ -10,6 +17,16 @@ function defaultIsUniqueViolation(err) {
|
|
|
10
17
|
function createProvisioningBridge(options) {
|
|
11
18
|
const { storage } = options;
|
|
12
19
|
const isUniqueViolation = options.isUniqueViolation ?? defaultIsUniqueViolation;
|
|
20
|
+
const allowUnverifiedEmailAdopt = options.allowUnverifiedEmailAdopt === true;
|
|
21
|
+
const emailVerified = (claims) => claims.email_verified === true;
|
|
22
|
+
const assertAdoptAllowed = (claims) => {
|
|
23
|
+
if (!allowUnverifiedEmailAdopt && !emailVerified(claims)) {
|
|
24
|
+
throw new ProvisioningError(
|
|
25
|
+
"UNVERIFIED_EMAIL_ADOPT_REFUSED",
|
|
26
|
+
"Refusing to adopt a pre-existing local account from an unverified email (claims.email_verified !== true). Set allowUnverifiedEmailAdopt:true only if your issuer is trusted to never emit unverified emails for adoption."
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
13
30
|
const roleOf = (claims) => {
|
|
14
31
|
try {
|
|
15
32
|
return options.roleMapper?.(claims) ?? null;
|
|
@@ -26,6 +43,7 @@ function createProvisioningBridge(options) {
|
|
|
26
43
|
if (claims.email) {
|
|
27
44
|
const byEmail = await storage.findByEmail(claims.email);
|
|
28
45
|
if (byEmail) {
|
|
46
|
+
assertAdoptAllowed(claims);
|
|
29
47
|
if (storage.adoptByEmail) {
|
|
30
48
|
const adopted = await storage.adoptByEmail(byEmail, claims, roleOf(claims));
|
|
31
49
|
return { user: adopted, claims, created: false, adopted: true };
|
|
@@ -41,7 +59,10 @@ function createProvisioningBridge(options) {
|
|
|
41
59
|
if (after) return { user: after, claims, created: false, adopted: false };
|
|
42
60
|
if (claims.email) {
|
|
43
61
|
const byEmail = await storage.findByEmail(claims.email);
|
|
44
|
-
if (byEmail)
|
|
62
|
+
if (byEmail) {
|
|
63
|
+
assertAdoptAllowed(claims);
|
|
64
|
+
return { user: byEmail, claims, created: false, adopted: true };
|
|
65
|
+
}
|
|
45
66
|
}
|
|
46
67
|
throw err;
|
|
47
68
|
}
|
|
@@ -50,5 +71,6 @@ function createProvisioningBridge(options) {
|
|
|
50
71
|
}
|
|
51
72
|
|
|
52
73
|
export {
|
|
74
|
+
ProvisioningError,
|
|
53
75
|
createProvisioningBridge
|
|
54
76
|
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// src/permissions/wildcard.ts
|
|
2
|
+
var SUFFIX = ".*";
|
|
3
|
+
function wildcardPrefix(pattern) {
|
|
4
|
+
return pattern.slice(0, -SUFFIX.length);
|
|
5
|
+
}
|
|
6
|
+
function hasPermission(set, id) {
|
|
7
|
+
if (!id) return false;
|
|
8
|
+
if (!set) return false;
|
|
9
|
+
if (id === "*") {
|
|
10
|
+
for (const entry of set) if (entry === "*") return true;
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
const queryIsWildcard = id.endsWith(SUFFIX);
|
|
14
|
+
const queryPrefix = queryIsWildcard ? wildcardPrefix(id) : null;
|
|
15
|
+
for (const entry of set) {
|
|
16
|
+
if (!entry) continue;
|
|
17
|
+
if (entry === "*") return true;
|
|
18
|
+
if (entry === id) return true;
|
|
19
|
+
if (entry.endsWith(SUFFIX)) {
|
|
20
|
+
const prefix = wildcardPrefix(entry);
|
|
21
|
+
if (!queryIsWildcard) {
|
|
22
|
+
if (id === prefix) return true;
|
|
23
|
+
if (id.startsWith(prefix + ".")) return true;
|
|
24
|
+
} else {
|
|
25
|
+
if (queryPrefix === prefix) return true;
|
|
26
|
+
if (queryPrefix !== null && queryPrefix.startsWith(prefix + ".")) return true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
function expandPermissions(set) {
|
|
33
|
+
if (!set) return [];
|
|
34
|
+
const seen = /* @__PURE__ */ new Set();
|
|
35
|
+
for (const raw of set) {
|
|
36
|
+
if (typeof raw !== "string" || raw.length === 0) continue;
|
|
37
|
+
seen.add(raw);
|
|
38
|
+
}
|
|
39
|
+
if (seen.has("*")) return ["*"];
|
|
40
|
+
const wildcards = [];
|
|
41
|
+
for (const entry of seen) if (entry.endsWith(SUFFIX)) wildcards.push(entry);
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const entry of seen) {
|
|
44
|
+
let covered = false;
|
|
45
|
+
for (const w of wildcards) {
|
|
46
|
+
if (w === entry) continue;
|
|
47
|
+
const prefix = wildcardPrefix(w);
|
|
48
|
+
if (entry === prefix) {
|
|
49
|
+
covered = true;
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
if (entry.startsWith(prefix + ".")) {
|
|
53
|
+
covered = true;
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (!covered) out.push(entry);
|
|
58
|
+
}
|
|
59
|
+
out.sort();
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export {
|
|
64
|
+
hasPermission,
|
|
65
|
+
expandPermissions
|
|
66
|
+
};
|