@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
package/dist/next.js
CHANGED
|
@@ -27,13 +27,30 @@ __export(next_exports, {
|
|
|
27
27
|
module.exports = __toCommonJS(next_exports);
|
|
28
28
|
|
|
29
29
|
// src/errors.ts
|
|
30
|
-
var IQAuthError = class extends Error {
|
|
31
|
-
constructor(code, message, status,
|
|
30
|
+
var IQAuthError = class _IQAuthError extends Error {
|
|
31
|
+
constructor(code, message, status, cause) {
|
|
32
32
|
super(message);
|
|
33
33
|
this.name = "IQAuthError";
|
|
34
34
|
this.code = code;
|
|
35
35
|
this.status = status;
|
|
36
|
-
this.
|
|
36
|
+
this.cause = cause;
|
|
37
|
+
this.raw = cause;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Type guard: true when `value` is an `IQAuthError`. Useful for adapters
|
|
41
|
+
* that round-trip errors through `unknown` (e.g. fastify's `setErrorHandler`).
|
|
42
|
+
*/
|
|
43
|
+
static isIQAuthError(value) {
|
|
44
|
+
return value instanceof _IQAuthError || typeof value === "object" && value !== null && value.name === "IQAuthError" && typeof value.code === "string";
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Type-narrowed code check. Lets callers write
|
|
48
|
+
* `if (err.is("token_expired")) …` with full IntelliSense for the typed
|
|
49
|
+
* taxonomy without losing the ability to handle server codes via
|
|
50
|
+
* `err.code === "TOKEN_REVOKED"`.
|
|
51
|
+
*/
|
|
52
|
+
is(code) {
|
|
53
|
+
return this.code === code;
|
|
37
54
|
}
|
|
38
55
|
};
|
|
39
56
|
|
|
@@ -66,14 +83,14 @@ function assertPublishableKey(raw, opts) {
|
|
|
66
83
|
const ctx = opts?.context ? `${opts.context}: ` : "";
|
|
67
84
|
if (typeof raw !== "string" || raw.length === 0) {
|
|
68
85
|
throw new IQAuthError(
|
|
69
|
-
"
|
|
86
|
+
"config_invalid",
|
|
70
87
|
`${ctx}IQAuth publishable key is missing. Set IQAUTH_PUBLISHABLE_KEY (or pass publishableKey) to a pk_test_\u2026 or pk_live_\u2026 value from the IQAuth admin console.`
|
|
71
88
|
);
|
|
72
89
|
}
|
|
73
90
|
const shapeMatch = raw.match(/^pk_(test|live)_([A-Za-z0-9_-]+)$/);
|
|
74
91
|
if (!shapeMatch) {
|
|
75
92
|
throw new IQAuthError(
|
|
76
|
-
"
|
|
93
|
+
"config_invalid",
|
|
77
94
|
`${ctx}IQAuth publishable key is malformed (got ${raw.slice(0, 12)}\u2026). Expected pk_test_\u2026 or pk_live_\u2026; regenerate the key from the IQAuth admin console.`
|
|
78
95
|
);
|
|
79
96
|
}
|
|
@@ -82,19 +99,19 @@ function assertPublishableKey(raw, opts) {
|
|
|
82
99
|
decoded = JSON.parse(b64urlDecode(shapeMatch[2]));
|
|
83
100
|
} catch {
|
|
84
101
|
throw new IQAuthError(
|
|
85
|
-
"
|
|
102
|
+
"config_invalid",
|
|
86
103
|
`${ctx}IQAuth publishable key payload is not valid base64url JSON. Regenerate the key from the IQAuth admin console.`
|
|
87
104
|
);
|
|
88
105
|
}
|
|
89
106
|
if (!isPublishableKeyPayload(decoded)) {
|
|
90
107
|
throw new IQAuthError(
|
|
91
|
-
"
|
|
108
|
+
"config_invalid",
|
|
92
109
|
`${ctx}IQAuth publishable key payload is missing required fields {iss, appId, tenantId, kid}. Regenerate the key from the IQAuth admin console.`
|
|
93
110
|
);
|
|
94
111
|
}
|
|
95
112
|
if (!isValidIssuerUrl(decoded.iss)) {
|
|
96
113
|
throw new IQAuthError(
|
|
97
|
-
"
|
|
114
|
+
"config_invalid",
|
|
98
115
|
`${ctx}IQAuth publishable key encodes an invalid issuer (iss=${JSON.stringify(decoded.iss)}). Expected a fully-qualified URL like "https://auth.example.com" (scheme required). Regenerate the key from the IQAuth admin console \u2014 the new key will encode a valid issuer URL.`
|
|
99
116
|
);
|
|
100
117
|
}
|
|
@@ -106,7 +123,271 @@ function isPublishableKeyPayload(value) {
|
|
|
106
123
|
return typeof v.iss === "string" && typeof v.appId === "string" && typeof v.tenantId === "string" && typeof v.kid === "string";
|
|
107
124
|
}
|
|
108
125
|
|
|
126
|
+
// src/modules/tokens.ts
|
|
127
|
+
var import_jose = require("jose");
|
|
128
|
+
var JWKS_CACHE_TTL_MS = 60 * 60 * 1e3;
|
|
129
|
+
var DEFAULT_TOKEN_ISSUER = [
|
|
130
|
+
"https://auth.dispositioniq.com",
|
|
131
|
+
"auth.dispositioniq.com"
|
|
132
|
+
];
|
|
133
|
+
var DEFAULT_TOKEN_AUDIENCE = [
|
|
134
|
+
"dispositioniq",
|
|
135
|
+
"iqcapture",
|
|
136
|
+
"iqreuse",
|
|
137
|
+
"iqvalidate"
|
|
138
|
+
];
|
|
139
|
+
var DEFAULT_CLOCK_TOLERANCE_SECONDS = 30;
|
|
140
|
+
function classifyJoseError(err) {
|
|
141
|
+
if (err instanceof import_jose.errors.JWTExpired) {
|
|
142
|
+
return { code: "token_expired", message: "Token has expired" };
|
|
143
|
+
}
|
|
144
|
+
if (err instanceof import_jose.errors.JOSEError) {
|
|
145
|
+
return { code: "token_invalid", message: err.message };
|
|
146
|
+
}
|
|
147
|
+
if (err instanceof Error) {
|
|
148
|
+
return { code: "token_invalid", message: err.message };
|
|
149
|
+
}
|
|
150
|
+
return { code: "token_invalid", message: "Token verification failed" };
|
|
151
|
+
}
|
|
152
|
+
function decodeProtectedHeader(token) {
|
|
153
|
+
const parts = token.split(".");
|
|
154
|
+
if (parts.length < 2) return null;
|
|
155
|
+
try {
|
|
156
|
+
const padded = parts[0] + "=".repeat((4 - parts[0].length % 4) % 4);
|
|
157
|
+
const b64 = padded.replace(/-/g, "+").replace(/_/g, "/");
|
|
158
|
+
let json;
|
|
159
|
+
if (typeof atob === "function") {
|
|
160
|
+
json = atob(b64);
|
|
161
|
+
} else {
|
|
162
|
+
const { Buffer: Buffer2 } = require("buffer");
|
|
163
|
+
json = Buffer2.from(b64, "base64").toString("utf8");
|
|
164
|
+
}
|
|
165
|
+
return JSON.parse(json);
|
|
166
|
+
} catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
var TokensModule = class {
|
|
171
|
+
constructor(baseUrl, options = {}) {
|
|
172
|
+
this.jwksCache = null;
|
|
173
|
+
this.inFlightRefresh = null;
|
|
174
|
+
this.baseUrl = baseUrl;
|
|
175
|
+
this.defaultIssuer = options.issuer ?? DEFAULT_TOKEN_ISSUER;
|
|
176
|
+
this.defaultAudience = options.audience ?? DEFAULT_TOKEN_AUDIENCE;
|
|
177
|
+
this.defaultClockTolerance = options.clockTolerance ?? DEFAULT_CLOCK_TOLERANCE_SECONDS;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Verify a JWT access token using RS256/ES256 via JWKS from
|
|
181
|
+
* `/.well-known/jwks.json`. Backed by `jose` (Web Crypto) so it runs on
|
|
182
|
+
* Node, browser, and edge runtimes alike — no `node:crypto` dependency.
|
|
183
|
+
* Caches JWKS for 1 hour and refetches once on unknown `kid`.
|
|
184
|
+
*/
|
|
185
|
+
async verify(token, options = {}) {
|
|
186
|
+
const header = decodeProtectedHeader(token);
|
|
187
|
+
if (!header) {
|
|
188
|
+
throw new IQAuthError("token_invalid", "Unable to decode token");
|
|
189
|
+
}
|
|
190
|
+
const kid = header.kid;
|
|
191
|
+
if (!kid) {
|
|
192
|
+
throw new IQAuthError("token_invalid", "Token missing kid header");
|
|
193
|
+
}
|
|
194
|
+
let cache = await this.ensureCache();
|
|
195
|
+
if (!cache.byKid.has(kid)) {
|
|
196
|
+
this.jwksCache = null;
|
|
197
|
+
cache = await this.ensureCache();
|
|
198
|
+
}
|
|
199
|
+
if (!cache.byKid.has(kid)) {
|
|
200
|
+
throw new IQAuthError("token_invalid", `Unknown key ID: ${kid}`);
|
|
201
|
+
}
|
|
202
|
+
const issuer = options.issuer ?? this.defaultIssuer;
|
|
203
|
+
const audience = options.audience ?? this.defaultAudience;
|
|
204
|
+
const clockTolerance = options.clockTolerance ?? this.defaultClockTolerance;
|
|
205
|
+
const algorithms = options.algorithms ?? ["RS256", "ES256"];
|
|
206
|
+
const verifyOptions = {
|
|
207
|
+
algorithms,
|
|
208
|
+
clockTolerance,
|
|
209
|
+
issuer,
|
|
210
|
+
audience
|
|
211
|
+
};
|
|
212
|
+
try {
|
|
213
|
+
const { payload } = await (0, import_jose.jwtVerify)(token, cache.verifier, verifyOptions);
|
|
214
|
+
return payload;
|
|
215
|
+
} catch (err) {
|
|
216
|
+
const classified = classifyJoseError(err);
|
|
217
|
+
throw new IQAuthError(classified.code, classified.message, void 0, err);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Decode a JWT without verification. Returns null if malformed.
|
|
222
|
+
*/
|
|
223
|
+
decode(token) {
|
|
224
|
+
try {
|
|
225
|
+
const parts = token.split(".");
|
|
226
|
+
if (parts.length < 2) return null;
|
|
227
|
+
const payload = parts[1];
|
|
228
|
+
const padded = payload + "=".repeat((4 - payload.length % 4) % 4);
|
|
229
|
+
const b64 = padded.replace(/-/g, "+").replace(/_/g, "/");
|
|
230
|
+
let json;
|
|
231
|
+
if (typeof atob === "function") {
|
|
232
|
+
json = atob(b64);
|
|
233
|
+
} else {
|
|
234
|
+
const { Buffer: Buffer2 } = require("buffer");
|
|
235
|
+
json = Buffer2.from(b64, "base64").toString("utf8");
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
json = decodeURIComponent(escape(json));
|
|
239
|
+
} catch {
|
|
240
|
+
}
|
|
241
|
+
const claims = JSON.parse(json);
|
|
242
|
+
if (!claims || typeof claims !== "object") return null;
|
|
243
|
+
return claims;
|
|
244
|
+
} catch {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/** Check if a token is expired based on the `exp` claim. */
|
|
249
|
+
isExpired(token) {
|
|
250
|
+
const claims = this.decode(token);
|
|
251
|
+
if (!claims?.exp) return true;
|
|
252
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
253
|
+
return claims.exp <= now;
|
|
254
|
+
}
|
|
255
|
+
/** Get the claims from a token without verification. */
|
|
256
|
+
getClaims(token) {
|
|
257
|
+
const claims = this.decode(token);
|
|
258
|
+
if (!claims) {
|
|
259
|
+
throw new IQAuthError("token_invalid", "Unable to decode token claims");
|
|
260
|
+
}
|
|
261
|
+
return claims;
|
|
262
|
+
}
|
|
263
|
+
async ensureCache() {
|
|
264
|
+
if (this.jwksCache && Date.now() - this.jwksCache.fetchedAt <= JWKS_CACHE_TTL_MS) {
|
|
265
|
+
return this.jwksCache;
|
|
266
|
+
}
|
|
267
|
+
await this.refreshJwks();
|
|
268
|
+
if (!this.jwksCache) {
|
|
269
|
+
throw new IQAuthError("jwks_unavailable", "JWKS cache unavailable after refresh");
|
|
270
|
+
}
|
|
271
|
+
return this.jwksCache;
|
|
272
|
+
}
|
|
273
|
+
async refreshJwks() {
|
|
274
|
+
if (this.inFlightRefresh) {
|
|
275
|
+
return this.inFlightRefresh;
|
|
276
|
+
}
|
|
277
|
+
this.inFlightRefresh = (async () => {
|
|
278
|
+
try {
|
|
279
|
+
let res;
|
|
280
|
+
try {
|
|
281
|
+
res = await fetch(`${this.baseUrl}/.well-known/jwks.json`);
|
|
282
|
+
} catch (err) {
|
|
283
|
+
throw new IQAuthError(
|
|
284
|
+
"network",
|
|
285
|
+
err instanceof Error ? err.message : "JWKS fetch network error",
|
|
286
|
+
void 0,
|
|
287
|
+
err
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
if (!res.ok) {
|
|
291
|
+
throw new IQAuthError(
|
|
292
|
+
"jwks_fetch_failed",
|
|
293
|
+
`Failed to fetch JWKS: ${res.status}`,
|
|
294
|
+
res.status
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
let jwks;
|
|
298
|
+
try {
|
|
299
|
+
jwks = await res.json();
|
|
300
|
+
} catch (err) {
|
|
301
|
+
throw new IQAuthError(
|
|
302
|
+
"jwks_fetch_failed",
|
|
303
|
+
"Malformed JWKS response: invalid JSON",
|
|
304
|
+
res.status,
|
|
305
|
+
err
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
if (!jwks || !Array.isArray(jwks.keys)) {
|
|
309
|
+
throw new IQAuthError(
|
|
310
|
+
"jwks_fetch_failed",
|
|
311
|
+
"Malformed JWKS response: expected { keys: [...] }"
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
const byKid = /* @__PURE__ */ new Set();
|
|
315
|
+
for (const key of jwks.keys) {
|
|
316
|
+
if (!key || typeof key.kid !== "string" || typeof key.n !== "string" && typeof key.x !== "string" || key.kty === "RSA" && (typeof key.n !== "string" || typeof key.e !== "string")) {
|
|
317
|
+
throw new IQAuthError(
|
|
318
|
+
"jwks_fetch_failed",
|
|
319
|
+
"Malformed JWKS response: key missing required fields"
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
byKid.add(key.kid);
|
|
323
|
+
}
|
|
324
|
+
const verifier = (0, import_jose.createLocalJWKSet)({ keys: jwks.keys });
|
|
325
|
+
this.jwksCache = { raw: jwks.keys, byKid, verifier, fetchedAt: Date.now() };
|
|
326
|
+
} finally {
|
|
327
|
+
this.inFlightRefresh = null;
|
|
328
|
+
}
|
|
329
|
+
})();
|
|
330
|
+
return this.inFlightRefresh;
|
|
331
|
+
}
|
|
332
|
+
/** @internal Exposed for testing — clears JWKS cache */
|
|
333
|
+
clearCache() {
|
|
334
|
+
this.jwksCache = null;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Task #126: Eagerly populate the JWKS cache so the first verify() call
|
|
338
|
+
* doesn't pay a network round-trip. Safe to call repeatedly — single-flight
|
|
339
|
+
* behavior is shared with the lazy refresh path. Errors are swallowed so
|
|
340
|
+
* callers (e.g. `attachHelpers` auto-prewarm) can fire-and-forget.
|
|
341
|
+
*/
|
|
342
|
+
async prewarm() {
|
|
343
|
+
if (this.jwksCache && Date.now() - this.jwksCache.fetchedAt <= JWKS_CACHE_TTL_MS) return;
|
|
344
|
+
try {
|
|
345
|
+
await this.refreshJwks();
|
|
346
|
+
} catch {
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
|
|
109
351
|
// src/server/handlers.ts
|
|
352
|
+
async function buildUserinfoResponse(claims, opts = {}) {
|
|
353
|
+
const baseUser = {
|
|
354
|
+
sub: claims.sub,
|
|
355
|
+
email: claims.email,
|
|
356
|
+
name: claims.name,
|
|
357
|
+
tenantId: claims.tenantId,
|
|
358
|
+
vendorId: claims.vendorId,
|
|
359
|
+
roles: claims.roles ?? [],
|
|
360
|
+
entitlements: claims.entitlements ?? [],
|
|
361
|
+
// Task #171 — project the active source/client scope onto the userinfo
|
|
362
|
+
// payload so server handlers (`getSessionUser`, `/api/iqauth/userinfo`)
|
|
363
|
+
// expose it without consumers having to re-decode the JWT.
|
|
364
|
+
...claims.scopeContext !== void 0 ? { scopeContext: claims.scopeContext } : {}
|
|
365
|
+
};
|
|
366
|
+
const enriched = opts.enrich ? await opts.enrich(claims) : null;
|
|
367
|
+
const user = enriched ? { ...baseUser, ...enriched } : baseUser;
|
|
368
|
+
return {
|
|
369
|
+
success: true,
|
|
370
|
+
data: {
|
|
371
|
+
user,
|
|
372
|
+
claims,
|
|
373
|
+
tenantId: claims.tenantId ?? null
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
function emitTiming(cfg, event) {
|
|
378
|
+
if (cfg.debug) {
|
|
379
|
+
try {
|
|
380
|
+
console.debug("[iqauth_helper]", event);
|
|
381
|
+
} catch {
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (cfg.onTimingEvent) {
|
|
385
|
+
try {
|
|
386
|
+
cfg.onTimingEvent(event);
|
|
387
|
+
} catch {
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
110
391
|
var TERMINAL_REFRESH_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
111
392
|
"TOKEN_REVOKED",
|
|
112
393
|
"SESSION_REVOKED",
|
|
@@ -125,19 +406,62 @@ function shouldClearCookiesOnFailure(policy, status, errorCode) {
|
|
|
125
406
|
}
|
|
126
407
|
var ACCESS_TOKEN_TTL_SECONDS = 60 * 15;
|
|
127
408
|
var REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30;
|
|
409
|
+
function assertCookiePrefixInvariants(name, secure, path, domain) {
|
|
410
|
+
if (name.startsWith("__Host-")) {
|
|
411
|
+
if (!secure) {
|
|
412
|
+
throw new IQAuthError(
|
|
413
|
+
"config_invalid",
|
|
414
|
+
`Cookie "${name}" uses the __Host- prefix, which browsers only accept on a Secure cookie. Set secure:true (and serve over HTTPS).`
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
if (path !== "/") {
|
|
418
|
+
throw new IQAuthError(
|
|
419
|
+
"config_invalid",
|
|
420
|
+
`Cookie "${name}" uses the __Host- prefix, which requires Path=/ (got "${path}"). Remove cookiePath or set it to "/".`
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
if (domain) {
|
|
424
|
+
throw new IQAuthError(
|
|
425
|
+
"config_invalid",
|
|
426
|
+
`Cookie "${name}" uses the __Host- prefix, which forbids a Domain attribute (the cookie is host-locked). Remove cookieDomain.`
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
} else if (name.startsWith("__Secure-") && !secure) {
|
|
430
|
+
throw new IQAuthError(
|
|
431
|
+
"config_invalid",
|
|
432
|
+
`Cookie "${name}" uses the __Secure- prefix, which browsers only accept on a Secure cookie. Set secure:true (and serve over HTTPS).`
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
128
436
|
function resolve(config) {
|
|
129
437
|
const parsed = assertPublishableKey(config.publishableKey, { context: "@iqauth/sdk helpers" });
|
|
130
438
|
const inferredIssuer = parsed.iss.startsWith("http") ? parsed.iss : `https://${parsed.iss}`;
|
|
439
|
+
maybeWarnDefaultSignoutRegistry(config);
|
|
440
|
+
const secure = config.secure ?? true;
|
|
441
|
+
if (config.secure === false && config.allowInsecureCookies !== true) {
|
|
442
|
+
throw new IQAuthError(
|
|
443
|
+
"config_invalid",
|
|
444
|
+
"Refusing to issue auth cookies with secure:false \u2014 this exposes session cookies over plaintext HTTP. For local HTTP development, set allowInsecureCookies:true to acknowledge the risk. Production MUST use HTTPS with secure cookies."
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
const accessCookieName = config.accessCookieName ?? config.cookieNames?.access ?? "iqauth_at";
|
|
448
|
+
const refreshCookieName = config.refreshCookieName ?? config.cookieNames?.refresh ?? "iqauth_rt";
|
|
449
|
+
const stateCookieName = config.stateCookieName ?? "iqauth_state";
|
|
450
|
+
const cookiePath = config.cookiePath ?? "/";
|
|
451
|
+
const cookieDomain = config.cookieDomain;
|
|
452
|
+
for (const name of [accessCookieName, refreshCookieName, stateCookieName]) {
|
|
453
|
+
assertCookiePrefixInvariants(name, secure, cookiePath, cookieDomain);
|
|
454
|
+
}
|
|
131
455
|
return {
|
|
132
456
|
publishableKey: config.publishableKey,
|
|
133
457
|
secretKey: config.secretKey,
|
|
134
458
|
issuer: (config.issuer ?? inferredIssuer).replace(/\/+$/, ""),
|
|
135
|
-
accessCookieName
|
|
136
|
-
refreshCookieName
|
|
137
|
-
cookieDomain
|
|
459
|
+
accessCookieName,
|
|
460
|
+
refreshCookieName,
|
|
461
|
+
cookieDomain,
|
|
138
462
|
sameSite: config.sameSite ?? "lax",
|
|
139
|
-
secure
|
|
140
|
-
cookiePath
|
|
463
|
+
secure,
|
|
464
|
+
cookiePath,
|
|
141
465
|
tokenPath: config.tokenPath ?? "/oidc/token",
|
|
142
466
|
refreshPath: config.refreshPath ?? "/api/v1/auth/refresh",
|
|
143
467
|
logoutPath: config.logoutPath ?? "/api/v1/auth/logout",
|
|
@@ -146,9 +470,23 @@ function resolve(config) {
|
|
|
146
470
|
})),
|
|
147
471
|
appId: parsed.appId,
|
|
148
472
|
tenantId: parsed.tenantId,
|
|
149
|
-
clearCookiesOnRefreshFailure: config.clearCookiesOnRefreshFailure ?? "terminal-only"
|
|
473
|
+
clearCookiesOnRefreshFailure: config.clearCookiesOnRefreshFailure ?? "terminal-only",
|
|
474
|
+
debug: config.debug,
|
|
475
|
+
onTimingEvent: config.onTimingEvent,
|
|
476
|
+
signoutRegistry: config.signoutRegistry ?? defaultSignoutRegistry,
|
|
477
|
+
signoutMarkerTtlMs: config.signoutMarkerTtlMs ?? DEFAULT_SIGNOUT_TTL_MS,
|
|
478
|
+
requireOAuthState: config.requireOAuthState ?? true,
|
|
479
|
+
stateCookieName: config.stateCookieName ?? "iqauth_state"
|
|
150
480
|
};
|
|
151
481
|
}
|
|
482
|
+
function timingSafeEqualStr(a, b) {
|
|
483
|
+
const len = Math.max(a.length, b.length);
|
|
484
|
+
let diff = a.length ^ b.length;
|
|
485
|
+
for (let i = 0; i < len; i++) {
|
|
486
|
+
diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
|
|
487
|
+
}
|
|
488
|
+
return diff === 0;
|
|
489
|
+
}
|
|
152
490
|
function makeCookie(cfg, name, value, maxAge, httpOnly = true) {
|
|
153
491
|
return {
|
|
154
492
|
name,
|
|
@@ -163,15 +501,53 @@ function makeCookie(cfg, name, value, maxAge, httpOnly = true) {
|
|
|
163
501
|
}
|
|
164
502
|
function clearCookies(cfg) {
|
|
165
503
|
return [
|
|
166
|
-
makeCookie(cfg, cfg.accessCookieName, "", 0),
|
|
167
|
-
makeCookie(cfg, cfg.refreshCookieName, "", 0)
|
|
504
|
+
{ ...makeCookie(cfg, cfg.accessCookieName, "", 0), clear: true },
|
|
505
|
+
{ ...makeCookie(cfg, cfg.refreshCookieName, "", 0), clear: true }
|
|
168
506
|
];
|
|
169
507
|
}
|
|
508
|
+
function clearStateCookie(cfg) {
|
|
509
|
+
return { ...makeCookie(cfg, cfg.stateCookieName, "", 0, false), clear: true };
|
|
510
|
+
}
|
|
511
|
+
var DEFAULT_SIGNOUT_TTL_MS = 6e4;
|
|
512
|
+
var inMemorySignoutMarkers = /* @__PURE__ */ new Map();
|
|
513
|
+
function pruneInMemoryMarkers(now) {
|
|
514
|
+
if (inMemorySignoutMarkers.size === 0) return;
|
|
515
|
+
for (const [k, exp] of inMemorySignoutMarkers) {
|
|
516
|
+
if (exp <= now) inMemorySignoutMarkers.delete(k);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
var defaultSignoutRegistry = {
|
|
520
|
+
mark(token, ttlMs) {
|
|
521
|
+
const now = Date.now();
|
|
522
|
+
pruneInMemoryMarkers(now);
|
|
523
|
+
inMemorySignoutMarkers.set(token, now + ttlMs);
|
|
524
|
+
},
|
|
525
|
+
has(token) {
|
|
526
|
+
const now = Date.now();
|
|
527
|
+
const exp = inMemorySignoutMarkers.get(token);
|
|
528
|
+
if (!exp) return false;
|
|
529
|
+
if (exp <= now) {
|
|
530
|
+
inMemorySignoutMarkers.delete(token);
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
return true;
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
var warnedDefaultSignoutRegistry = false;
|
|
537
|
+
function maybeWarnDefaultSignoutRegistry(config) {
|
|
538
|
+
if (warnedDefaultSignoutRegistry) return;
|
|
539
|
+
if (config.signoutRegistry) return;
|
|
540
|
+
warnedDefaultSignoutRegistry = true;
|
|
541
|
+
console.warn(
|
|
542
|
+
"[IQAuth] Using the in-memory signout registry (process-local). Signout idempotency is NOT shared across instances \u2014 in a multi-replica deployment a /refresh racing a /signout on another replica can reissue cookies after sign-out. Plug a shared backend (e.g. Redis) into IQAuthHelperConfig.signoutRegistry to fix this and silence this warning."
|
|
543
|
+
);
|
|
544
|
+
}
|
|
170
545
|
function serializeCookie(d) {
|
|
171
546
|
const parts = [`${d.name}=${encodeURIComponent(d.value)}`];
|
|
172
547
|
parts.push(`Path=${d.path}`);
|
|
173
548
|
if (d.domain) parts.push(`Domain=${d.domain}`);
|
|
174
549
|
parts.push(`Max-Age=${d.maxAge}`);
|
|
550
|
+
if (d.clear) parts.push("Expires=Thu, 01 Jan 1970 00:00:00 GMT");
|
|
175
551
|
if (d.secure) parts.push("Secure");
|
|
176
552
|
if (d.httpOnly) parts.push("HttpOnly");
|
|
177
553
|
parts.push(`SameSite=${d.sameSite}`);
|
|
@@ -179,14 +555,34 @@ function serializeCookie(d) {
|
|
|
179
555
|
}
|
|
180
556
|
async function handleCallback(config, input) {
|
|
181
557
|
const cfg = resolve(config);
|
|
558
|
+
const t0 = Date.now();
|
|
182
559
|
if (!input.code || !input.redirectUri) {
|
|
560
|
+
emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code: "VALIDATION_ERROR" });
|
|
183
561
|
return {
|
|
184
562
|
status: 400,
|
|
185
563
|
body: { success: false, error: { code: "VALIDATION_ERROR", message: "code and redirectUri are required" } },
|
|
186
564
|
cookies: []
|
|
187
565
|
};
|
|
188
566
|
}
|
|
567
|
+
const provided = input.state;
|
|
568
|
+
const expected = input.expectedState;
|
|
569
|
+
const stateOk = cfg.requireOAuthState ? !!expected && !!provided && timingSafeEqualStr(provided, expected) : !expected || !!provided && timingSafeEqualStr(provided, expected);
|
|
570
|
+
if (!stateOk) {
|
|
571
|
+
emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code: "STATE_MISMATCH" });
|
|
572
|
+
return {
|
|
573
|
+
status: 400,
|
|
574
|
+
body: {
|
|
575
|
+
success: false,
|
|
576
|
+
error: {
|
|
577
|
+
code: "STATE_MISMATCH",
|
|
578
|
+
message: "OAuth state validation failed; the sign-in could not be verified as originating from this browser."
|
|
579
|
+
}
|
|
580
|
+
},
|
|
581
|
+
cookies: [clearStateCookie(cfg)]
|
|
582
|
+
};
|
|
583
|
+
}
|
|
189
584
|
if (!cfg.secretKey) {
|
|
585
|
+
emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code: "INTERNAL_ERROR" });
|
|
190
586
|
return {
|
|
191
587
|
status: 500,
|
|
192
588
|
body: { success: false, error: { code: "INTERNAL_ERROR", message: "secretKey is required for the callback handler" } },
|
|
@@ -210,6 +606,7 @@ async function handleCallback(config, input) {
|
|
|
210
606
|
});
|
|
211
607
|
const json = await res.json().catch(() => ({}));
|
|
212
608
|
if (!res.ok || !json.access_token) {
|
|
609
|
+
emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code: json.error || "OIDC_EXCHANGE_FAILED" });
|
|
213
610
|
return {
|
|
214
611
|
status: res.status || 502,
|
|
215
612
|
body: {
|
|
@@ -222,6 +619,26 @@ async function handleCallback(config, input) {
|
|
|
222
619
|
cookies: []
|
|
223
620
|
};
|
|
224
621
|
}
|
|
622
|
+
try {
|
|
623
|
+
await getTokensFor(cfg.issuer).verify(json.access_token, {
|
|
624
|
+
issuer: cfg.issuer,
|
|
625
|
+
...config.verify
|
|
626
|
+
});
|
|
627
|
+
} catch (err) {
|
|
628
|
+
const code = err instanceof IQAuthError ? err.code : err.code || "TOKEN_INVALID";
|
|
629
|
+
emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code });
|
|
630
|
+
return {
|
|
631
|
+
status: 502,
|
|
632
|
+
body: {
|
|
633
|
+
success: false,
|
|
634
|
+
error: {
|
|
635
|
+
code: "ACCESS_TOKEN_VERIFICATION_FAILED",
|
|
636
|
+
message: "The issuer returned an access token that failed verification; no session was established."
|
|
637
|
+
}
|
|
638
|
+
},
|
|
639
|
+
cookies: []
|
|
640
|
+
};
|
|
641
|
+
}
|
|
225
642
|
const cookies = [];
|
|
226
643
|
cookies.push(
|
|
227
644
|
makeCookie(cfg, cfg.accessCookieName, json.access_token, json.expires_in ?? ACCESS_TOKEN_TTL_SECONDS)
|
|
@@ -229,6 +646,8 @@ async function handleCallback(config, input) {
|
|
|
229
646
|
if (json.refresh_token) {
|
|
230
647
|
cookies.push(makeCookie(cfg, cfg.refreshCookieName, json.refresh_token, REFRESH_TOKEN_TTL_SECONDS));
|
|
231
648
|
}
|
|
649
|
+
cookies.push(clearStateCookie(cfg));
|
|
650
|
+
emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: true });
|
|
232
651
|
return {
|
|
233
652
|
status: 200,
|
|
234
653
|
body: { success: true, data: { authenticated: true } },
|
|
@@ -237,8 +656,18 @@ async function handleCallback(config, input) {
|
|
|
237
656
|
}
|
|
238
657
|
async function handleRefresh(config, input) {
|
|
239
658
|
const cfg = resolve(config);
|
|
659
|
+
const t0 = Date.now();
|
|
240
660
|
const refreshToken = input.refreshToken;
|
|
661
|
+
const idemKey = input.idempotencyToken;
|
|
662
|
+
if (idemKey && await Promise.resolve(cfg.signoutRegistry.has(idemKey))) {
|
|
663
|
+
return {
|
|
664
|
+
status: 401,
|
|
665
|
+
body: { success: false, error: { code: "SESSION_REVOKED", message: "Session was signed out" } },
|
|
666
|
+
cookies: clearCookies(cfg)
|
|
667
|
+
};
|
|
668
|
+
}
|
|
241
669
|
if (!refreshToken) {
|
|
670
|
+
emitTiming(cfg, { phase: "refresh", durationMs: Date.now() - t0, ok: false, code: "TOKEN_INVALID" });
|
|
242
671
|
return {
|
|
243
672
|
status: 401,
|
|
244
673
|
body: { success: false, error: { code: "TOKEN_INVALID", message: "Missing refresh token" } },
|
|
@@ -254,6 +683,7 @@ async function handleRefresh(config, input) {
|
|
|
254
683
|
if (!res.ok || !json.success || !json.data?.accessToken) {
|
|
255
684
|
const status = res.status || 401;
|
|
256
685
|
const errorCode = json.error?.code || "TOKEN_INVALID";
|
|
686
|
+
emitTiming(cfg, { phase: "refresh", durationMs: Date.now() - t0, ok: false, code: errorCode });
|
|
257
687
|
const shouldClear = shouldClearCookiesOnFailure(
|
|
258
688
|
cfg.clearCookiesOnRefreshFailure,
|
|
259
689
|
status,
|
|
@@ -277,6 +707,7 @@ async function handleRefresh(config, input) {
|
|
|
277
707
|
if (json.data.refreshToken) {
|
|
278
708
|
cookies.push(makeCookie(cfg, cfg.refreshCookieName, json.data.refreshToken, REFRESH_TOKEN_TTL_SECONDS));
|
|
279
709
|
}
|
|
710
|
+
emitTiming(cfg, { phase: "refresh", durationMs: Date.now() - t0, ok: true });
|
|
280
711
|
return {
|
|
281
712
|
status: 200,
|
|
282
713
|
body: { success: true, data: { accessToken: json.data.accessToken } },
|
|
@@ -285,6 +716,10 @@ async function handleRefresh(config, input) {
|
|
|
285
716
|
}
|
|
286
717
|
async function handleSignout(config, input) {
|
|
287
718
|
const cfg = resolve(config);
|
|
719
|
+
const t0 = Date.now();
|
|
720
|
+
if (input.idempotencyToken) {
|
|
721
|
+
await Promise.resolve(cfg.signoutRegistry.mark(input.idempotencyToken, cfg.signoutMarkerTtlMs));
|
|
722
|
+
}
|
|
288
723
|
if (input.accessToken) {
|
|
289
724
|
try {
|
|
290
725
|
await cfg.fetchImpl(`${cfg.issuer}${cfg.logoutPath}`, {
|
|
@@ -306,206 +741,94 @@ async function handleSignout(config, input) {
|
|
|
306
741
|
} catch {
|
|
307
742
|
}
|
|
308
743
|
}
|
|
744
|
+
emitTiming(cfg, { phase: "signout", durationMs: Date.now() - t0, ok: true });
|
|
309
745
|
return {
|
|
310
746
|
status: 200,
|
|
311
747
|
body: { success: true, data: { signedOut: true } },
|
|
312
748
|
cookies: clearCookies(cfg)
|
|
313
749
|
};
|
|
314
750
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
"auth.dispositioniq.com"
|
|
322
|
-
];
|
|
323
|
-
var DEFAULT_TOKEN_AUDIENCE = [
|
|
324
|
-
"dispositioniq",
|
|
325
|
-
"iqcapture",
|
|
326
|
-
"iqreuse",
|
|
327
|
-
"iqvalidate"
|
|
328
|
-
];
|
|
329
|
-
var DEFAULT_CLOCK_TOLERANCE_SECONDS = 30;
|
|
330
|
-
function decodeProtectedHeader(token) {
|
|
331
|
-
const parts = token.split(".");
|
|
332
|
-
if (parts.length < 2) return null;
|
|
333
|
-
try {
|
|
334
|
-
const padded = parts[0] + "=".repeat((4 - parts[0].length % 4) % 4);
|
|
335
|
-
const b64 = padded.replace(/-/g, "+").replace(/_/g, "/");
|
|
336
|
-
let json;
|
|
337
|
-
if (typeof atob === "function") {
|
|
338
|
-
json = atob(b64);
|
|
339
|
-
} else {
|
|
340
|
-
const { Buffer: Buffer2 } = require("buffer");
|
|
341
|
-
json = Buffer2.from(b64, "base64").toString("utf8");
|
|
342
|
-
}
|
|
343
|
-
return JSON.parse(json);
|
|
344
|
-
} catch {
|
|
345
|
-
return null;
|
|
751
|
+
var TOKENS_CACHE = /* @__PURE__ */ new Map();
|
|
752
|
+
function getTokensFor(issuer) {
|
|
753
|
+
let m = TOKENS_CACHE.get(issuer);
|
|
754
|
+
if (!m) {
|
|
755
|
+
m = new TokensModule(issuer);
|
|
756
|
+
TOKENS_CACHE.set(issuer, m);
|
|
346
757
|
}
|
|
758
|
+
return m;
|
|
347
759
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
this.defaultClockTolerance = options.clockTolerance ?? DEFAULT_CLOCK_TOLERANCE_SECONDS;
|
|
356
|
-
}
|
|
357
|
-
/**
|
|
358
|
-
* Verify a JWT access token using RS256/ES256 via JWKS from
|
|
359
|
-
* `/.well-known/jwks.json`. Backed by `jose` (Web Crypto) so it runs on
|
|
360
|
-
* Node, browser, and edge runtimes alike — no `node:crypto` dependency.
|
|
361
|
-
* Caches JWKS for 1 hour and refetches once on unknown `kid`.
|
|
362
|
-
*/
|
|
363
|
-
async verify(token, options = {}) {
|
|
364
|
-
const header = decodeProtectedHeader(token);
|
|
365
|
-
if (!header) {
|
|
366
|
-
throw new IQAuthError("TOKEN_INVALID", "Unable to decode token");
|
|
367
|
-
}
|
|
368
|
-
const kid = header.kid;
|
|
369
|
-
if (!kid) {
|
|
370
|
-
throw new IQAuthError("TOKEN_INVALID", "Token missing kid header");
|
|
371
|
-
}
|
|
372
|
-
let cache = await this.ensureCache();
|
|
373
|
-
if (!cache.byKid.has(kid)) {
|
|
374
|
-
this.jwksCache = null;
|
|
375
|
-
cache = await this.ensureCache();
|
|
376
|
-
}
|
|
377
|
-
if (!cache.byKid.has(kid)) {
|
|
378
|
-
throw new IQAuthError("TOKEN_INVALID", `Unknown key ID: ${kid}`);
|
|
379
|
-
}
|
|
380
|
-
const issuer = options.issuer ?? this.defaultIssuer;
|
|
381
|
-
const audience = options.audience ?? this.defaultAudience;
|
|
382
|
-
const clockTolerance = options.clockTolerance ?? this.defaultClockTolerance;
|
|
383
|
-
const algorithms = options.algorithms ?? ["RS256", "ES256"];
|
|
384
|
-
const verifyOptions = {
|
|
385
|
-
algorithms,
|
|
386
|
-
clockTolerance,
|
|
387
|
-
issuer,
|
|
388
|
-
audience
|
|
760
|
+
async function handleUserinfo(config, input) {
|
|
761
|
+
const cfg = resolve(config);
|
|
762
|
+
if (!input.accessToken) {
|
|
763
|
+
return {
|
|
764
|
+
status: 401,
|
|
765
|
+
body: { success: false, error: { code: "TOKEN_INVALID", message: "Missing access token" } },
|
|
766
|
+
cookies: []
|
|
389
767
|
};
|
|
390
|
-
try {
|
|
391
|
-
const { payload } = await (0, import_jose.jwtVerify)(token, cache.verifier, verifyOptions);
|
|
392
|
-
return payload;
|
|
393
|
-
} catch (err) {
|
|
394
|
-
if (err instanceof import_jose.errors.JWTExpired) {
|
|
395
|
-
throw new IQAuthError("TOKEN_EXPIRED", "Token has expired");
|
|
396
|
-
}
|
|
397
|
-
if (err instanceof import_jose.errors.JOSEError) {
|
|
398
|
-
throw new IQAuthError("TOKEN_INVALID", err.message);
|
|
399
|
-
}
|
|
400
|
-
if (err instanceof Error) {
|
|
401
|
-
throw new IQAuthError("TOKEN_INVALID", err.message);
|
|
402
|
-
}
|
|
403
|
-
throw new IQAuthError("TOKEN_INVALID", "Token verification failed");
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
/**
|
|
407
|
-
* Decode a JWT without verification. Returns null if malformed.
|
|
408
|
-
*/
|
|
409
|
-
decode(token) {
|
|
410
|
-
try {
|
|
411
|
-
const parts = token.split(".");
|
|
412
|
-
if (parts.length < 2) return null;
|
|
413
|
-
const payload = parts[1];
|
|
414
|
-
const padded = payload + "=".repeat((4 - payload.length % 4) % 4);
|
|
415
|
-
const b64 = padded.replace(/-/g, "+").replace(/_/g, "/");
|
|
416
|
-
let json;
|
|
417
|
-
if (typeof atob === "function") {
|
|
418
|
-
json = atob(b64);
|
|
419
|
-
} else {
|
|
420
|
-
const { Buffer: Buffer2 } = require("buffer");
|
|
421
|
-
json = Buffer2.from(b64, "base64").toString("utf8");
|
|
422
|
-
}
|
|
423
|
-
try {
|
|
424
|
-
json = decodeURIComponent(escape(json));
|
|
425
|
-
} catch {
|
|
426
|
-
}
|
|
427
|
-
const claims = JSON.parse(json);
|
|
428
|
-
if (!claims || typeof claims !== "object") return null;
|
|
429
|
-
return claims;
|
|
430
|
-
} catch {
|
|
431
|
-
return null;
|
|
432
|
-
}
|
|
433
768
|
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
769
|
+
let claims;
|
|
770
|
+
try {
|
|
771
|
+
claims = await getTokensFor(cfg.issuer).verify(input.accessToken, {
|
|
772
|
+
issuer: cfg.issuer,
|
|
773
|
+
...config.verify
|
|
774
|
+
});
|
|
775
|
+
} catch (err) {
|
|
776
|
+
const code = err instanceof IQAuthError ? err.code : err.code || "TOKEN_INVALID";
|
|
777
|
+
const message = err instanceof Error ? err.message : "Access token verification failed";
|
|
778
|
+
return {
|
|
779
|
+
status: 401,
|
|
780
|
+
body: { success: false, error: { code, message } },
|
|
781
|
+
cookies: []
|
|
782
|
+
};
|
|
440
783
|
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
784
|
+
const envelope = await buildUserinfoResponse(claims, {
|
|
785
|
+
enrich: config.userinfoEnricher ? (c) => config.userinfoEnricher(c, input.req) : void 0
|
|
786
|
+
});
|
|
787
|
+
return {
|
|
788
|
+
status: 200,
|
|
789
|
+
body: envelope,
|
|
790
|
+
cookies: []
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// src/browser/returnTo.ts
|
|
795
|
+
function normalizeOrigin(o) {
|
|
796
|
+
try {
|
|
797
|
+
return new URL(o).origin;
|
|
798
|
+
} catch {
|
|
799
|
+
return o.replace(/\/+$/, "");
|
|
448
800
|
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
801
|
+
}
|
|
802
|
+
function sanitizeReturnTo(input, options = {}) {
|
|
803
|
+
const fallback = options.fallback ?? "/";
|
|
804
|
+
if (!input || typeof input !== "string") return fallback;
|
|
805
|
+
const trimmed = input.trim();
|
|
806
|
+
if (!trimmed) return fallback;
|
|
807
|
+
if (trimmed.includes("\\")) return fallback;
|
|
808
|
+
if (trimmed.startsWith("//")) return fallback;
|
|
809
|
+
if (trimmed.startsWith("/") || trimmed.startsWith("#") || trimmed.startsWith("?")) {
|
|
810
|
+
return trimmed;
|
|
458
811
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
return this.inFlightRefresh;
|
|
462
|
-
}
|
|
463
|
-
this.inFlightRefresh = (async () => {
|
|
464
|
-
try {
|
|
465
|
-
const res = await fetch(`${this.baseUrl}/.well-known/jwks.json`);
|
|
466
|
-
if (!res.ok) {
|
|
467
|
-
throw new IQAuthError(
|
|
468
|
-
"INTERNAL_ERROR",
|
|
469
|
-
`Failed to fetch JWKS: ${res.status}`
|
|
470
|
-
);
|
|
471
|
-
}
|
|
472
|
-
let jwks;
|
|
473
|
-
try {
|
|
474
|
-
jwks = await res.json();
|
|
475
|
-
} catch {
|
|
476
|
-
throw new IQAuthError("INTERNAL_ERROR", "Malformed JWKS response: invalid JSON");
|
|
477
|
-
}
|
|
478
|
-
if (!jwks || !Array.isArray(jwks.keys)) {
|
|
479
|
-
throw new IQAuthError(
|
|
480
|
-
"INTERNAL_ERROR",
|
|
481
|
-
"Malformed JWKS response: expected { keys: [...] }"
|
|
482
|
-
);
|
|
483
|
-
}
|
|
484
|
-
const byKid = /* @__PURE__ */ new Set();
|
|
485
|
-
for (const key of jwks.keys) {
|
|
486
|
-
if (!key || typeof key.kid !== "string" || typeof key.n !== "string" && typeof key.x !== "string" || key.kty === "RSA" && (typeof key.n !== "string" || typeof key.e !== "string")) {
|
|
487
|
-
throw new IQAuthError(
|
|
488
|
-
"INTERNAL_ERROR",
|
|
489
|
-
"Malformed JWKS response: key missing required fields"
|
|
490
|
-
);
|
|
491
|
-
}
|
|
492
|
-
byKid.add(key.kid);
|
|
493
|
-
}
|
|
494
|
-
const verifier = (0, import_jose.createLocalJWKSet)({ keys: jwks.keys });
|
|
495
|
-
this.jwksCache = { raw: jwks.keys, byKid, verifier, fetchedAt: Date.now() };
|
|
496
|
-
} finally {
|
|
497
|
-
this.inFlightRefresh = null;
|
|
498
|
-
}
|
|
499
|
-
})();
|
|
500
|
-
return this.inFlightRefresh;
|
|
812
|
+
if (!/^[a-z][a-z0-9+\-.]*:/i.test(trimmed)) {
|
|
813
|
+
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
501
814
|
}
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
815
|
+
let parsed;
|
|
816
|
+
try {
|
|
817
|
+
parsed = new URL(trimmed);
|
|
818
|
+
} catch {
|
|
819
|
+
return fallback;
|
|
505
820
|
}
|
|
506
|
-
|
|
821
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return fallback;
|
|
822
|
+
const currentOrigin = options.currentOrigin ?? (typeof window !== "undefined" ? window.location.origin : "");
|
|
823
|
+
const allowed = /* @__PURE__ */ new Set();
|
|
824
|
+
if (currentOrigin) allowed.add(normalizeOrigin(currentOrigin));
|
|
825
|
+
for (const o of options.allowedOrigins ?? []) allowed.add(normalizeOrigin(o));
|
|
826
|
+
if (allowed.has(parsed.origin)) return parsed.toString();
|
|
827
|
+
return fallback;
|
|
828
|
+
}
|
|
507
829
|
|
|
508
830
|
// src/next.ts
|
|
831
|
+
var PKCE_COOKIE = "iqauth_pkce";
|
|
509
832
|
function readCookieFromHeader(header, name) {
|
|
510
833
|
if (!header) return void 0;
|
|
511
834
|
const target = `${name}=`;
|
|
@@ -526,32 +849,100 @@ function toResponse(hr) {
|
|
|
526
849
|
for (const c of hr.cookies) headers.append("set-cookie", serializeCookie(c));
|
|
527
850
|
return new Response(JSON.stringify(hr.body), { status: hr.status, headers });
|
|
528
851
|
}
|
|
852
|
+
function callbackResponse(hr, requestOrigin, returnToCookieValue, returnToCookieName) {
|
|
853
|
+
const returnTo = sanitizeReturnTo(
|
|
854
|
+
returnToCookieValue || hr.body?.returnTo,
|
|
855
|
+
{ currentOrigin: requestOrigin, fallback: "/" }
|
|
856
|
+
);
|
|
857
|
+
const headers = new Headers({ "Content-Type": "application/json" });
|
|
858
|
+
for (const c of hr.cookies) headers.append("set-cookie", serializeCookie(c));
|
|
859
|
+
if (hr.status < 400) {
|
|
860
|
+
headers.append("set-cookie", `${returnToCookieName}=; Path=/; Max-Age=0; SameSite=Lax`);
|
|
861
|
+
}
|
|
862
|
+
const body = { ...hr.body, returnTo };
|
|
863
|
+
return new Response(JSON.stringify(body), { status: hr.status, headers });
|
|
864
|
+
}
|
|
865
|
+
function callbackRedirectResponse(hr, requestOrigin, returnToCookieValue, cookieNames) {
|
|
866
|
+
const headers = new Headers();
|
|
867
|
+
for (const c of hr.cookies) headers.append("set-cookie", serializeCookie(c));
|
|
868
|
+
headers.append("set-cookie", `${cookieNames.state}=; Path=/; Max-Age=0; SameSite=Lax`);
|
|
869
|
+
headers.append("set-cookie", `${cookieNames.pkce}=; Path=/; Max-Age=0; SameSite=Lax`);
|
|
870
|
+
if (hr.status >= 400) {
|
|
871
|
+
headers.set("Location", "/");
|
|
872
|
+
return new Response(null, { status: 302, headers });
|
|
873
|
+
}
|
|
874
|
+
const dest = sanitizeReturnTo(returnToCookieValue, {
|
|
875
|
+
currentOrigin: requestOrigin,
|
|
876
|
+
fallback: "/"
|
|
877
|
+
});
|
|
878
|
+
headers.append("set-cookie", `${cookieNames.returnTo}=; Path=/; Max-Age=0; SameSite=Lax`);
|
|
879
|
+
headers.set("Location", dest);
|
|
880
|
+
return new Response(null, { status: 302, headers });
|
|
881
|
+
}
|
|
529
882
|
function handler(options) {
|
|
530
883
|
const parsed = assertPublishableKey(options.publishableKey, { context: "@iqauth/sdk/next handler" });
|
|
531
884
|
const issuer = (options.issuer ?? (parsed.iss.startsWith("http") ? parsed.iss : `https://${parsed.iss}`)).replace(/\/+$/, "");
|
|
532
885
|
const helperConfig = { ...options, issuer };
|
|
533
886
|
const accessCookie = options.accessCookieName ?? "iqauth_at";
|
|
534
887
|
const refreshCookie = options.refreshCookieName ?? "iqauth_rt";
|
|
888
|
+
const returnToCookie = options.returnToCookieName ?? "iqauth_return_to";
|
|
535
889
|
return async (req) => {
|
|
536
890
|
const url = new URL(req.url);
|
|
537
891
|
const action = url.pathname.split("/").pop();
|
|
538
|
-
const body = await req.json().catch(() => ({}));
|
|
539
892
|
const cookieHeader = req.headers.get("cookie");
|
|
893
|
+
if (action === "me" && req.method === "GET") {
|
|
894
|
+
if (!options.mountUserinfo) {
|
|
895
|
+
return new Response(JSON.stringify({ success: false, error: { code: "NOT_FOUND", message: "userinfo route not enabled" } }), {
|
|
896
|
+
status: 404,
|
|
897
|
+
headers: { "Content-Type": "application/json" }
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
const auth = req.headers.get("authorization");
|
|
901
|
+
const accessToken = auth && auth.replace(/^Bearer /i, "") || readCookieFromHeader(cookieHeader, accessCookie);
|
|
902
|
+
return toResponse(await handleUserinfo(helperConfig, { accessToken, req }));
|
|
903
|
+
}
|
|
904
|
+
const stateCookie = helperConfig.stateCookieName ?? "iqauth_state";
|
|
905
|
+
if (action === "callback" && req.method === "GET") {
|
|
906
|
+
const code = url.searchParams.get("code") ?? void 0;
|
|
907
|
+
const state = url.searchParams.get("state") ?? void 0;
|
|
908
|
+
const redirectUri = `${url.origin}${url.pathname}`;
|
|
909
|
+
const hr = await handleCallback(helperConfig, {
|
|
910
|
+
code,
|
|
911
|
+
codeVerifier: readCookieFromHeader(cookieHeader, PKCE_COOKIE),
|
|
912
|
+
redirectUri,
|
|
913
|
+
state,
|
|
914
|
+
expectedState: readCookieFromHeader(cookieHeader, stateCookie)
|
|
915
|
+
});
|
|
916
|
+
return callbackRedirectResponse(
|
|
917
|
+
hr,
|
|
918
|
+
url.origin,
|
|
919
|
+
readCookieFromHeader(cookieHeader, returnToCookie),
|
|
920
|
+
{ returnTo: returnToCookie, state: stateCookie, pkce: PKCE_COOKIE }
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
const body = await req.json().catch(() => ({}));
|
|
540
924
|
if (action === "callback") {
|
|
541
|
-
|
|
925
|
+
const hr = await handleCallback(helperConfig, {
|
|
542
926
|
code: body.code,
|
|
543
927
|
codeVerifier: body.codeVerifier,
|
|
544
|
-
redirectUri: body.redirectUri
|
|
545
|
-
|
|
928
|
+
redirectUri: body.redirectUri,
|
|
929
|
+
// M-2: bind callback to this browser; handleCallback fails closed.
|
|
930
|
+
state: body.state,
|
|
931
|
+
expectedState: readCookieFromHeader(cookieHeader, helperConfig.stateCookieName ?? "iqauth_state")
|
|
932
|
+
});
|
|
933
|
+
return callbackResponse(hr, url.origin, readCookieFromHeader(cookieHeader, returnToCookie), returnToCookie);
|
|
546
934
|
}
|
|
547
935
|
if (action === "refresh") {
|
|
548
936
|
const refreshToken = body.refreshToken || readCookieFromHeader(cookieHeader, refreshCookie);
|
|
549
|
-
|
|
937
|
+
const idempotencyToken = req.headers.get("x-iqauth-idempotency") || body.idempotencyToken;
|
|
938
|
+
return toResponse(await handleRefresh(helperConfig, { refreshToken, idempotencyToken: idempotencyToken ?? void 0 }));
|
|
550
939
|
}
|
|
551
940
|
if (action === "signout") {
|
|
552
941
|
const auth = req.headers.get("authorization");
|
|
553
942
|
const accessToken = auth && auth.replace(/^Bearer /i, "") || readCookieFromHeader(cookieHeader, accessCookie);
|
|
554
|
-
|
|
943
|
+
const refreshToken = readCookieFromHeader(cookieHeader, refreshCookie);
|
|
944
|
+
const idempotencyToken = req.headers.get("x-iqauth-idempotency") ?? void 0;
|
|
945
|
+
return toResponse(await handleSignout(helperConfig, { accessToken, refreshToken, idempotencyToken, ssoCookieHeader: cookieHeader ?? void 0 }));
|
|
555
946
|
}
|
|
556
947
|
return new Response(JSON.stringify({ success: false, error: { code: "NOT_FOUND", message: `Unknown action: ${action}` } }), {
|
|
557
948
|
status: 404,
|