@nihplod/sso-sdk 1.0.1 → 1.1.0

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.
@@ -21,6 +21,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var next_exports = {};
22
22
  __export(next_exports, {
23
23
  DEFAULT_ACCESS_TOKEN_COOKIE_NAME: () => DEFAULT_ACCESS_TOKEN_COOKIE_NAME,
24
+ DEFAULT_ID_TOKEN_COOKIE_NAME: () => DEFAULT_ID_TOKEN_COOKIE_NAME,
25
+ DEFAULT_LOGOUT_STATE_COOKIE_NAME: () => DEFAULT_LOGOUT_STATE_COOKIE_NAME,
24
26
  DEFAULT_REFRESH_TOKEN_COOKIE_NAME: () => DEFAULT_REFRESH_TOKEN_COOKIE_NAME,
25
27
  DEFAULT_RETURN_COOKIE_NAME: () => DEFAULT_RETURN_COOKIE_NAME,
26
28
  DEFAULT_STATE_COOKIE_NAME: () => DEFAULT_STATE_COOKIE_NAME,
@@ -43,6 +45,7 @@ var DEFAULT_ID_TOKEN_COOKIE_NAME = "__Host-nihplod_sso_id";
43
45
  var DEFAULT_STATE_COOKIE_NAME = "__Host-nihplod_sso_state";
44
46
  var DEFAULT_RETURN_COOKIE_NAME = "__Host-nihplod_sso_return";
45
47
  var DEFAULT_VERIFIER_COOKIE_NAME = "__Secure-nihplod_sso_verifier";
48
+ var DEFAULT_LOGOUT_STATE_COOKIE_NAME = "__Host-nihplod_sso_logout_state";
46
49
  function getHostCookieOptions(maxAge) {
47
50
  return {
48
51
  httpOnly: true,
@@ -96,7 +99,43 @@ function matchesPath(pathname, paths) {
96
99
  return false;
97
100
  });
98
101
  }
102
+ var introspectionCache = /* @__PURE__ */ new Map();
103
+ var INTROSPECT_CACHE_TTL_MS = 3e4;
104
+ var INTROSPECT_CACHE_MAX_ENTRIES = 500;
105
+ async function introspectCacheKey(token, clientId) {
106
+ const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
107
+ const hex = Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
108
+ return `${hex}|${clientId}`;
109
+ }
110
+ function introspectCacheGet(key) {
111
+ const entry = introspectionCache.get(key);
112
+ if (!entry) return null;
113
+ if (entry.until <= Date.now()) {
114
+ introspectionCache.delete(key);
115
+ return null;
116
+ }
117
+ introspectionCache.delete(key);
118
+ introspectionCache.set(key, entry);
119
+ return entry;
120
+ }
121
+ function introspectCacheSet(key, active) {
122
+ const now = Date.now();
123
+ for (const [k, v] of introspectionCache) {
124
+ if (v.until <= now) introspectionCache.delete(k);
125
+ }
126
+ while (introspectionCache.size >= INTROSPECT_CACHE_MAX_ENTRIES) {
127
+ const oldest = introspectionCache.keys().next();
128
+ if (oldest.done) break;
129
+ introspectionCache.delete(oldest.value);
130
+ }
131
+ introspectionCache.set(key, { active, until: now + INTROSPECT_CACHE_TTL_MS });
132
+ }
99
133
  async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret) {
134
+ const cacheKey = await introspectCacheKey(token, clientId);
135
+ const cached = introspectCacheGet(cacheKey);
136
+ if (cached) {
137
+ return cached.active;
138
+ }
100
139
  try {
101
140
  const body = new URLSearchParams({
102
141
  token,
@@ -111,9 +150,17 @@ async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret)
111
150
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
112
151
  body: body.toString()
113
152
  });
114
- if (!res.ok) return false;
153
+ if (!res.ok) {
154
+ if (res.status !== 401 && res.status !== 403) {
155
+ return false;
156
+ }
157
+ introspectCacheSet(cacheKey, false);
158
+ return false;
159
+ }
115
160
  const data = await res.json();
116
- return data.active === true;
161
+ const active = data.active === true;
162
+ introspectCacheSet(cacheKey, active);
163
+ return active;
117
164
  } catch {
118
165
  return false;
119
166
  }
@@ -128,12 +175,25 @@ function createSsoMiddleware(config) {
128
175
  publicPaths = [],
129
176
  callbackPath = "/api/auth/callback",
130
177
  ssoCookieName = "__Host-user_token",
178
+ validateSsoCookie = true,
131
179
  accessTokenCookieName = DEFAULT_ACCESS_TOKEN_COOKIE_NAME,
132
180
  stateCookieName = DEFAULT_STATE_COOKIE_NAME,
133
181
  returnUrlCookieName = DEFAULT_RETURN_COOKIE_NAME,
134
182
  verifierCookieName = DEFAULT_VERIFIER_COOKIE_NAME
135
183
  } = config;
136
184
  const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
185
+ if (process.env.NODE_ENV !== "production") {
186
+ if (!validateSsoCookie) {
187
+ console.warn(
188
+ "[SSO SDK] validateSsoCookie=false\uFF1A\u4E2D\u95F4\u4EF6\u4EC5\u68C0\u67E5 Cookie \u5B58\u5728\u6027\uFF0C\u53EF\u80FD\u653E\u884C\u5DF2\u5931\u6548\u7684\u4F1A\u8BDD\u3002\u4E2D\u95F4\u4EF6\u53EA\u662F UX \u5C42\uFF0C\u654F\u611F\u6570\u636E\u7684\u9274\u6743\u5FC5\u987B\u5728 Route Handler / Server Component \u4E2D\u5B8C\u6210\u3002"
189
+ );
190
+ }
191
+ if (!clientSecret) {
192
+ console.warn(
193
+ "[SSO SDK] \u672A\u914D\u7F6E clientSecret\uFF08Public Client \u6A21\u5F0F\uFF09\uFF1Aintrospection \u65E0\u5BA2\u6237\u7AEF\u8BA4\u8BC1\uFF0C\u4E2D\u95F4\u4EF6\u5224\u5B9A\u7ED3\u679C\u4EC5\u4F5C UX \u53C2\u8003\u3002Confidential Client\uFF08BFF\uFF09\u8BF7\u914D\u7F6E clientSecret\u3002"
194
+ );
195
+ }
196
+ }
137
197
  return async function ssoMiddleware(request) {
138
198
  const { pathname } = request.nextUrl;
139
199
  if (pathname.startsWith("/_next/") || pathname.startsWith("/favicon.ico") || pathname.match(/\.(ico|png|jpg|jpeg|svg|css|js|woff2?)$/)) {
@@ -148,7 +208,19 @@ function createSsoMiddleware(config) {
148
208
  }
149
209
  const ssoSession = request.cookies.get(ssoCookieName);
150
210
  if (ssoSession?.value) {
151
- return import_server.NextResponse.next();
211
+ if (validateSsoCookie) {
212
+ const tokenActive = await introspectAccessToken(
213
+ ssoSession.value,
214
+ normalizedBase,
215
+ clientId,
216
+ clientSecret
217
+ );
218
+ if (tokenActive) {
219
+ return import_server.NextResponse.next();
220
+ }
221
+ } else {
222
+ return import_server.NextResponse.next();
223
+ }
152
224
  }
153
225
  const accessTokenCookie = request.cookies.get(accessTokenCookieName);
154
226
  if (accessTokenCookie?.value) {
@@ -178,7 +250,8 @@ function createSsoMiddleware(config) {
178
250
  const response = import_server.NextResponse.redirect(loginUrl);
179
251
  response.cookies.set(stateCookieName, state, getHostCookieOptions(600));
180
252
  response.cookies.set(verifierCookieName, verifier, getSecureCookieOptions(600, callbackPath));
181
- response.cookies.set(returnUrlCookieName, request.url, getHostCookieOptions(600));
253
+ const safeReturnUrl = (request.nextUrl.pathname + request.nextUrl.search).slice(0, 2048);
254
+ response.cookies.set(returnUrlCookieName, safeReturnUrl, getHostCookieOptions(600));
182
255
  if (accessTokenCookie?.value) {
183
256
  response.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0));
184
257
  }
@@ -188,6 +261,19 @@ function createSsoMiddleware(config) {
188
261
 
189
262
  // src/next/callback.ts
190
263
  var import_server2 = require("next/server");
264
+
265
+ // src/core/errors.ts
266
+ var SsoError = class extends Error {
267
+ constructor(code, description, cause) {
268
+ super(`[SSO SDK] ${code}: ${description}`);
269
+ this.name = "SsoError";
270
+ this.code = code;
271
+ this.description = description;
272
+ this.cause = cause;
273
+ }
274
+ };
275
+
276
+ // src/core/security.ts
191
277
  function isTrustedReturnUrl(url, currentOrigin) {
192
278
  if (!url) return false;
193
279
  if (url.startsWith("/") && !url.startsWith("//")) return true;
@@ -197,6 +283,23 @@ function isTrustedReturnUrl(url, currentOrigin) {
197
283
  return false;
198
284
  }
199
285
  }
286
+ function timingSafeEqualString(a, b) {
287
+ const ba = new TextEncoder().encode(a);
288
+ const bb = new TextEncoder().encode(b);
289
+ if (ba.length === 0 || bb.length === 0) return ba.length === bb.length;
290
+ const len = Math.max(ba.length, bb.length);
291
+ let diff = ba.length ^ bb.length;
292
+ for (let i = 0; i < len; i++) {
293
+ diff |= ba[i % ba.length] ^ bb[i % bb.length];
294
+ }
295
+ return diff === 0;
296
+ }
297
+
298
+ // src/core/id-token.ts
299
+ var _crypto = typeof crypto !== "undefined" && crypto.subtle ? crypto : null;
300
+ function getCrypto() {
301
+ return _crypto ?? null;
302
+ }
200
303
  function base64UrlDecode(input) {
201
304
  const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
202
305
  const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
@@ -227,11 +330,40 @@ function decodeJwtHeader(token) {
227
330
  return null;
228
331
  }
229
332
  }
230
- async function fetchJwks(baseUrl) {
333
+ function normalizeIssuer(url) {
334
+ return url.replace(/\/+$/, "");
335
+ }
336
+ var cachedJwks = null;
337
+ var cachedDiscovery = null;
338
+ var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
339
+ async function fetchDiscoveryDoc(baseUrl) {
340
+ const now = Date.now();
341
+ if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
342
+ return cachedDiscovery.doc;
343
+ }
231
344
  try {
232
- const res = await fetch(`${baseUrl}/api/oauth/jwks`);
345
+ const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`);
346
+ const doc = res.ok ? await res.json() : null;
347
+ cachedDiscovery = { baseUrl, doc, fetchedAt: now };
348
+ return doc;
349
+ } catch {
350
+ cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
351
+ return null;
352
+ }
353
+ }
354
+ async function fetchJwks(baseUrl, options = {}) {
355
+ const now = Date.now();
356
+ if (!options.forceRefresh && cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
357
+ return cachedJwks.jwks;
358
+ }
359
+ const discovery = await fetchDiscoveryDoc(baseUrl);
360
+ const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
361
+ try {
362
+ const res = await fetch(jwksUri, options.forceRefresh ? { cache: "no-cache" } : void 0);
233
363
  if (!res.ok) return null;
234
- return await res.json();
364
+ const jwks = await res.json();
365
+ cachedJwks = { baseUrl, jwks, fetchedAt: now };
366
+ return jwks;
235
367
  } catch {
236
368
  return null;
237
369
  }
@@ -240,7 +372,9 @@ async function verifyRs256Signature(token, jwk) {
240
372
  try {
241
373
  const [headerB64, payloadB64, signature] = token.split(".");
242
374
  if (!signature || !jwk.n || !jwk.e) return false;
243
- const cryptoKey = await crypto.subtle.importKey(
375
+ const c = getCrypto();
376
+ if (!c) return false;
377
+ const cryptoKey = await c.subtle.importKey(
244
378
  "jwk",
245
379
  { kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: false },
246
380
  { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
@@ -249,7 +383,7 @@ async function verifyRs256Signature(token, jwk) {
249
383
  );
250
384
  const signatureBytes = base64UrlDecode(signature);
251
385
  const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
252
- return await crypto.subtle.verify(
386
+ return await c.subtle.verify(
253
387
  "RSASSA-PKCS1-v1_5",
254
388
  cryptoKey,
255
389
  signatureBytes,
@@ -260,10 +394,9 @@ async function verifyRs256Signature(token, jwk) {
260
394
  }
261
395
  }
262
396
  async function computeAtHash(accessToken) {
263
- const hash = await crypto.subtle.digest(
264
- "SHA-256",
265
- new TextEncoder().encode(accessToken)
266
- );
397
+ const c = getCrypto();
398
+ if (!c) return "";
399
+ const hash = await c.subtle.digest("SHA-256", new TextEncoder().encode(accessToken));
267
400
  const bytes = new Uint8Array(hash);
268
401
  const half = bytes.slice(0, bytes.length / 2);
269
402
  let binary = "";
@@ -272,54 +405,101 @@ async function computeAtHash(accessToken) {
272
405
  }
273
406
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
274
407
  }
275
- async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId) {
408
+ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
409
+ const { rejectHs256WhenRs256Available = true } = options;
276
410
  const header = decodeJwtHeader(idToken);
277
- if (!header) throw new Error("ID Token \u683C\u5F0F\u9519\u8BEF");
411
+ if (!header) {
412
+ throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
413
+ }
278
414
  const alg = header.alg;
279
415
  if (typeof alg !== "string" || alg !== "RS256" && alg !== "HS256") {
280
- throw new Error(`\u4E0D\u652F\u6301\u7684 ID Token \u7B7E\u540D\u7B97\u6CD5: ${alg}`);
416
+ throw new SsoError("id_token_unsupported_alg", `\u4E0D\u652F\u6301\u7684 ID Token \u7B7E\u540D\u7B97\u6CD5: ${alg}`);
281
417
  }
418
+ const baseUrl = normalizeIssuer(expectedIssuer);
419
+ const discovery = await fetchDiscoveryDoc(baseUrl);
420
+ const normalizedIssuer = normalizeIssuer(discovery?.issuer || baseUrl);
282
421
  if (alg === "RS256") {
283
- const jwks = await fetchJwks(expectedIssuer);
284
- if (!jwks) throw new Error("\u65E0\u6CD5\u83B7\u53D6 JWKS");
422
+ const jwks = await fetchJwks(baseUrl);
423
+ if (!jwks) {
424
+ throw new SsoError("id_token_invalid_signature", "\u65E0\u6CD5\u83B7\u53D6 JWKS \u9A8C\u8BC1 ID Token \u7B7E\u540D");
425
+ }
285
426
  const kid = typeof header.kid === "string" ? header.kid : void 0;
286
- const key = jwks.keys.find(
427
+ const matchCandidates = (set) => set.keys.filter(
287
428
  (k) => k.kty === "RSA" && k.alg === "RS256" && k.use === "sig" && (kid ? k.kid === kid : true)
288
429
  );
289
- if (!key) throw new Error("JWKS \u4E2D\u672A\u627E\u5230\u5339\u914D\u516C\u94A5");
290
- const valid = await verifyRs256Signature(idToken, key);
291
- if (!valid) throw new Error("ID Token \u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25");
430
+ const verifyAny = async (keys) => {
431
+ for (const key of keys) {
432
+ if (await verifyRs256Signature(idToken, key)) return true;
433
+ }
434
+ return false;
435
+ };
436
+ let candidates = matchCandidates(jwks);
437
+ let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
438
+ if (candidates.length === 0 || !validSig) {
439
+ const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });
440
+ if (freshJwks) {
441
+ candidates = matchCandidates(freshJwks);
442
+ validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
443
+ }
444
+ }
445
+ if (candidates.length === 0) {
446
+ throw new SsoError("id_token_invalid_signature", "JWKS \u4E2D\u672A\u627E\u5230\u5339\u914D\u7684 RS256 \u516C\u94A5");
447
+ }
448
+ if (!validSig) {
449
+ throw new SsoError("id_token_invalid_signature", "ID Token \u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25");
450
+ }
292
451
  }
293
452
  if (alg === "HS256") {
294
- console.warn(
295
- "[SSO SDK/Next] ID Token \u4F7F\u7528 HS256 \u7B7E\u540D\u3002\u5EFA\u8BAE\u4E3B\u7AD9\u542F\u7528 RS256 \u4EE5\u83B7\u5F97\u5B8C\u6574\u7B7E\u540D\u9A8C\u8BC1\u3002"
453
+ if (rejectHs256WhenRs256Available) {
454
+ const jwks = await fetchJwks(baseUrl);
455
+ const hasRs256 = jwks?.keys?.some((k) => k.alg === "RS256" && k.use === "sig");
456
+ if (hasRs256) {
457
+ throw new SsoError("id_token_unsupported_alg", "SSO \u4E2D\u5FC3\u5DF2\u914D\u7F6E RS256\uFF0C\u62D2\u7EDD HS256 ID Token");
458
+ }
459
+ }
460
+ throw new SsoError(
461
+ "id_token_hs256_unsupported",
462
+ "ID Token \u4F7F\u7528 HS256 \u5BF9\u79F0\u7B7E\u540D\uFF0CSDK \u65E0\u6CD5\u5B89\u5168\u9A8C\u8BC1\u3002\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u5728 SSO \u4E3B\u7AD9\u914D\u7F6E RS256 \u5BC6\u94A5\u5BF9\uFF08JWT_ID_TOKEN_PRIVATE_KEY / JWT_ID_TOKEN_PUBLIC_KEY\uFF09\u540E\u91CD\u65B0\u7B7E\u53D1\u3002"
296
463
  );
297
464
  }
298
465
  const payload = decodeJwtPayload(idToken);
299
- if (!payload) throw new Error("ID Token payload \u89E3\u6790\u5931\u8D25");
300
- const normalizedIssuer = expectedIssuer.replace(/\/+$/, "");
301
- const tokenIssuer = typeof payload.iss === "string" ? payload.iss.replace(/\/+$/, "") : "";
466
+ if (!payload) {
467
+ throw new SsoError("id_token_invalid", "ID Token payload \u89E3\u6790\u5931\u8D25");
468
+ }
469
+ const tokenIssuer = typeof payload.iss === "string" ? normalizeIssuer(payload.iss) : "";
302
470
  if (tokenIssuer !== normalizedIssuer) {
303
- throw new Error("ID Token issuer \u4E0D\u5339\u914D");
471
+ throw new SsoError("id_token_issuer_mismatch", "ID Token issuer \u4E0D\u5339\u914D");
304
472
  }
305
473
  const aud = payload.aud;
306
- const audArr = Array.isArray(aud) ? aud : [aud];
307
- if (!audArr.includes(expectedClientId)) {
308
- throw new Error("ID Token audience \u4E0D\u5339\u914D");
474
+ const audList = Array.isArray(aud) ? aud : typeof aud === "string" ? [aud] : [];
475
+ if (!audList.includes(expectedClientId)) {
476
+ throw new SsoError("id_token_audience_mismatch", "ID Token audience \u4E0D\u5339\u914D");
477
+ }
478
+ if (audList.length > 1 && payload.azp !== expectedClientId) {
479
+ throw new SsoError("id_token_audience_mismatch", "ID Token \u591A audience \u65F6 azp \u5FC5\u987B\u4E3A\u5F53\u524D client");
480
+ }
481
+ if (typeof payload.exp !== "number") {
482
+ throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 exp \u58F0\u660E");
483
+ }
484
+ if (Date.now() >= payload.exp * 1e3) {
485
+ throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
309
486
  }
310
- if (typeof payload.exp === "number" && Date.now() >= payload.exp * 1e3) {
311
- throw new Error("ID Token \u5DF2\u8FC7\u671F");
487
+ if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
488
+ throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
312
489
  }
313
490
  if (typeof payload.sub !== "string" || !payload.sub) {
314
- throw new Error("ID Token \u7F3A\u5C11 sub");
491
+ throw new SsoError("id_token_missing_sub", "ID Token \u7F3A\u5C11 sub");
315
492
  }
316
493
  if (typeof payload.at_hash === "string" && payload.at_hash) {
317
494
  const actual = await computeAtHash(accessToken);
318
- if (actual !== payload.at_hash) {
319
- throw new Error("ID Token at_hash \u4E0D\u5339\u914D");
495
+ if (!timingSafeEqualString(actual, payload.at_hash)) {
496
+ throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
320
497
  }
321
498
  }
499
+ return { sub: payload.sub };
322
500
  }
501
+
502
+ // src/next/callback.ts
323
503
  function createCallbackRouteHandler(config) {
324
504
  const {
325
505
  clientId,
@@ -395,7 +575,7 @@ function createCallbackRouteHandler(config) {
395
575
  if (clientSecret) {
396
576
  body.set("client_secret", clientSecret);
397
577
  }
398
- let res;
578
+ let res = null;
399
579
  let lastError;
400
580
  const maxRetries = 1;
401
581
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
@@ -470,9 +650,24 @@ function createCallbackRouteHandler(config) {
470
650
  ...getHostCookieOptions(refreshMaxAge)
471
651
  });
472
652
  if (tokenData.id_token) {
473
- response.cookies.set(idTokenCookieName, tokenData.id_token, {
474
- ...getHostCookieOptions(refreshMaxAge)
475
- });
653
+ let idTokenMaxAge = 3600;
654
+ try {
655
+ const parts = tokenData.id_token.split(".");
656
+ if (parts.length === 3) {
657
+ const payload = JSON.parse(
658
+ Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")
659
+ );
660
+ if (payload.exp && typeof payload.exp === "number") {
661
+ idTokenMaxAge = payload.exp - Math.floor(Date.now() / 1e3);
662
+ }
663
+ }
664
+ } catch {
665
+ }
666
+ if (idTokenMaxAge > 0) {
667
+ response.cookies.set(idTokenCookieName, tokenData.id_token, {
668
+ ...getHostCookieOptions(idTokenMaxAge)
669
+ });
670
+ }
476
671
  }
477
672
  response.cookies.set(stateCookieName, "", getHostCookieOptions(0));
478
673
  response.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0));
@@ -485,9 +680,9 @@ function createCallbackRouteHandler(config) {
485
680
  // src/next/logout.ts
486
681
  var import_server3 = require("next/server");
487
682
  async function fetchDiscovery(ssoBaseUrl) {
683
+ const controller = new AbortController();
684
+ const timeoutId = setTimeout(() => controller.abort(), 5e3);
488
685
  try {
489
- const controller = new AbortController();
490
- setTimeout(() => controller.abort(), 5e3);
491
686
  const res = await fetch(
492
687
  `${ssoBaseUrl}/api/oauth/.well-known/openid-configuration`,
493
688
  { signal: controller.signal }
@@ -496,8 +691,24 @@ async function fetchDiscovery(ssoBaseUrl) {
496
691
  return await res.json();
497
692
  } catch {
498
693
  return null;
694
+ } finally {
695
+ clearTimeout(timeoutId);
499
696
  }
500
697
  }
698
+ function generateRandomString2(length) {
699
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
700
+ const maxValid = Math.floor(256 / chars.length) * chars.length;
701
+ let result = "";
702
+ while (result.length < length) {
703
+ const array = new Uint8Array(length * 2);
704
+ crypto.getRandomValues(array);
705
+ for (let i = 0; i < array.length && result.length < length; i++) {
706
+ if (array[i] >= maxValid) continue;
707
+ result += chars[array[i] % chars.length];
708
+ }
709
+ }
710
+ return result;
711
+ }
501
712
  function createLogoutRouteHandler(config) {
502
713
  const {
503
714
  clientId,
@@ -512,10 +723,24 @@ function createLogoutRouteHandler(config) {
512
723
  stateCookieName = DEFAULT_STATE_COOKIE_NAME,
513
724
  returnUrlCookieName = DEFAULT_RETURN_COOKIE_NAME,
514
725
  verifierCookieName = DEFAULT_VERIFIER_COOKIE_NAME,
515
- callbackPath = "/api/auth/callback"
726
+ callbackPath = "/api/auth/callback",
727
+ logoutStateCookieName = DEFAULT_LOGOUT_STATE_COOKIE_NAME
516
728
  } = config;
517
729
  const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
518
- return async function GET(request) {
730
+ return async function handler(request) {
731
+ const returnedState = request.nextUrl.searchParams.get("state");
732
+ if (returnedState) {
733
+ const savedState = request.cookies.get(logoutStateCookieName)?.value;
734
+ if (!savedState || savedState !== returnedState) {
735
+ return import_server3.NextResponse.json(
736
+ { error: "invalid_request", error_description: "Logout state \u4E0D\u5339\u914D" },
737
+ { status: 400 }
738
+ );
739
+ }
740
+ const res = import_server3.NextResponse.redirect(request.nextUrl.origin + "/");
741
+ res.cookies.set(logoutStateCookieName, "", getHostCookieOptions(0));
742
+ return res;
743
+ }
519
744
  const refreshToken = request.cookies.get(refreshTokenCookieName)?.value;
520
745
  const idTokenHint = request.cookies.get(idTokenCookieName)?.value;
521
746
  if (refreshToken) {
@@ -560,7 +785,11 @@ function createLogoutRouteHandler(config) {
560
785
  if (idTokenHint) {
561
786
  logoutUrl.searchParams.set("id_token_hint", idTokenHint);
562
787
  }
563
- return clearCookies(import_server3.NextResponse.redirect(logoutUrl.toString()));
788
+ const logoutState = generateRandomString2(32);
789
+ logoutUrl.searchParams.set("state", logoutState);
790
+ const res = clearCookies(import_server3.NextResponse.redirect(logoutUrl.toString()));
791
+ res.cookies.set(logoutStateCookieName, logoutState, getHostCookieOptions(600));
792
+ return res;
564
793
  }
565
794
  return clearCookies(
566
795
  import_server3.NextResponse.redirect(request.nextUrl.origin + "/")
@@ -570,6 +799,8 @@ function createLogoutRouteHandler(config) {
570
799
  // Annotate the CommonJS export names for ESM import in node:
571
800
  0 && (module.exports = {
572
801
  DEFAULT_ACCESS_TOKEN_COOKIE_NAME,
802
+ DEFAULT_ID_TOKEN_COOKIE_NAME,
803
+ DEFAULT_LOGOUT_STATE_COOKIE_NAME,
573
804
  DEFAULT_REFRESH_TOKEN_COOKIE_NAME,
574
805
  DEFAULT_RETURN_COOKIE_NAME,
575
806
  DEFAULT_STATE_COOKIE_NAME,