@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.
@@ -8,6 +8,7 @@ var DEFAULT_ID_TOKEN_COOKIE_NAME = "__Host-nihplod_sso_id";
8
8
  var DEFAULT_STATE_COOKIE_NAME = "__Host-nihplod_sso_state";
9
9
  var DEFAULT_RETURN_COOKIE_NAME = "__Host-nihplod_sso_return";
10
10
  var DEFAULT_VERIFIER_COOKIE_NAME = "__Secure-nihplod_sso_verifier";
11
+ var DEFAULT_LOGOUT_STATE_COOKIE_NAME = "__Host-nihplod_sso_logout_state";
11
12
  function getHostCookieOptions(maxAge) {
12
13
  return {
13
14
  httpOnly: true,
@@ -61,7 +62,43 @@ function matchesPath(pathname, paths) {
61
62
  return false;
62
63
  });
63
64
  }
65
+ var introspectionCache = /* @__PURE__ */ new Map();
66
+ var INTROSPECT_CACHE_TTL_MS = 3e4;
67
+ var INTROSPECT_CACHE_MAX_ENTRIES = 500;
68
+ async function introspectCacheKey(token, clientId) {
69
+ const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
70
+ const hex = Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
71
+ return `${hex}|${clientId}`;
72
+ }
73
+ function introspectCacheGet(key) {
74
+ const entry = introspectionCache.get(key);
75
+ if (!entry) return null;
76
+ if (entry.until <= Date.now()) {
77
+ introspectionCache.delete(key);
78
+ return null;
79
+ }
80
+ introspectionCache.delete(key);
81
+ introspectionCache.set(key, entry);
82
+ return entry;
83
+ }
84
+ function introspectCacheSet(key, active) {
85
+ const now = Date.now();
86
+ for (const [k, v] of introspectionCache) {
87
+ if (v.until <= now) introspectionCache.delete(k);
88
+ }
89
+ while (introspectionCache.size >= INTROSPECT_CACHE_MAX_ENTRIES) {
90
+ const oldest = introspectionCache.keys().next();
91
+ if (oldest.done) break;
92
+ introspectionCache.delete(oldest.value);
93
+ }
94
+ introspectionCache.set(key, { active, until: now + INTROSPECT_CACHE_TTL_MS });
95
+ }
64
96
  async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret) {
97
+ const cacheKey = await introspectCacheKey(token, clientId);
98
+ const cached = introspectCacheGet(cacheKey);
99
+ if (cached) {
100
+ return cached.active;
101
+ }
65
102
  try {
66
103
  const body = new URLSearchParams({
67
104
  token,
@@ -76,9 +113,17 @@ async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret)
76
113
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
77
114
  body: body.toString()
78
115
  });
79
- if (!res.ok) return false;
116
+ if (!res.ok) {
117
+ if (res.status !== 401 && res.status !== 403) {
118
+ return false;
119
+ }
120
+ introspectCacheSet(cacheKey, false);
121
+ return false;
122
+ }
80
123
  const data = await res.json();
81
- return data.active === true;
124
+ const active = data.active === true;
125
+ introspectCacheSet(cacheKey, active);
126
+ return active;
82
127
  } catch {
83
128
  return false;
84
129
  }
@@ -93,12 +138,25 @@ function createSsoMiddleware(config) {
93
138
  publicPaths = [],
94
139
  callbackPath = "/api/auth/callback",
95
140
  ssoCookieName = "__Host-user_token",
141
+ validateSsoCookie = true,
96
142
  accessTokenCookieName = DEFAULT_ACCESS_TOKEN_COOKIE_NAME,
97
143
  stateCookieName = DEFAULT_STATE_COOKIE_NAME,
98
144
  returnUrlCookieName = DEFAULT_RETURN_COOKIE_NAME,
99
145
  verifierCookieName = DEFAULT_VERIFIER_COOKIE_NAME
100
146
  } = config;
101
147
  const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
148
+ if (process.env.NODE_ENV !== "production") {
149
+ if (!validateSsoCookie) {
150
+ console.warn(
151
+ "[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"
152
+ );
153
+ }
154
+ if (!clientSecret) {
155
+ console.warn(
156
+ "[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"
157
+ );
158
+ }
159
+ }
102
160
  return async function ssoMiddleware(request) {
103
161
  const { pathname } = request.nextUrl;
104
162
  if (pathname.startsWith("/_next/") || pathname.startsWith("/favicon.ico") || pathname.match(/\.(ico|png|jpg|jpeg|svg|css|js|woff2?)$/)) {
@@ -113,7 +171,19 @@ function createSsoMiddleware(config) {
113
171
  }
114
172
  const ssoSession = request.cookies.get(ssoCookieName);
115
173
  if (ssoSession?.value) {
116
- return NextResponse.next();
174
+ if (validateSsoCookie) {
175
+ const tokenActive = await introspectAccessToken(
176
+ ssoSession.value,
177
+ normalizedBase,
178
+ clientId,
179
+ clientSecret
180
+ );
181
+ if (tokenActive) {
182
+ return NextResponse.next();
183
+ }
184
+ } else {
185
+ return NextResponse.next();
186
+ }
117
187
  }
118
188
  const accessTokenCookie = request.cookies.get(accessTokenCookieName);
119
189
  if (accessTokenCookie?.value) {
@@ -143,7 +213,8 @@ function createSsoMiddleware(config) {
143
213
  const response = NextResponse.redirect(loginUrl);
144
214
  response.cookies.set(stateCookieName, state, getHostCookieOptions(600));
145
215
  response.cookies.set(verifierCookieName, verifier, getSecureCookieOptions(600, callbackPath));
146
- response.cookies.set(returnUrlCookieName, request.url, getHostCookieOptions(600));
216
+ const safeReturnUrl = (request.nextUrl.pathname + request.nextUrl.search).slice(0, 2048);
217
+ response.cookies.set(returnUrlCookieName, safeReturnUrl, getHostCookieOptions(600));
147
218
  if (accessTokenCookie?.value) {
148
219
  response.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0));
149
220
  }
@@ -153,6 +224,19 @@ function createSsoMiddleware(config) {
153
224
 
154
225
  // src/next/callback.ts
155
226
  import { NextResponse as NextResponse2 } from "next/server";
227
+
228
+ // src/core/errors.ts
229
+ var SsoError = class extends Error {
230
+ constructor(code, description, cause) {
231
+ super(`[SSO SDK] ${code}: ${description}`);
232
+ this.name = "SsoError";
233
+ this.code = code;
234
+ this.description = description;
235
+ this.cause = cause;
236
+ }
237
+ };
238
+
239
+ // src/core/security.ts
156
240
  function isTrustedReturnUrl(url, currentOrigin) {
157
241
  if (!url) return false;
158
242
  if (url.startsWith("/") && !url.startsWith("//")) return true;
@@ -162,6 +246,23 @@ function isTrustedReturnUrl(url, currentOrigin) {
162
246
  return false;
163
247
  }
164
248
  }
249
+ function timingSafeEqualString(a, b) {
250
+ const ba = new TextEncoder().encode(a);
251
+ const bb = new TextEncoder().encode(b);
252
+ if (ba.length === 0 || bb.length === 0) return ba.length === bb.length;
253
+ const len = Math.max(ba.length, bb.length);
254
+ let diff = ba.length ^ bb.length;
255
+ for (let i = 0; i < len; i++) {
256
+ diff |= ba[i % ba.length] ^ bb[i % bb.length];
257
+ }
258
+ return diff === 0;
259
+ }
260
+
261
+ // src/core/id-token.ts
262
+ var _crypto = typeof crypto !== "undefined" && crypto.subtle ? crypto : null;
263
+ function getCrypto() {
264
+ return _crypto ?? null;
265
+ }
165
266
  function base64UrlDecode(input) {
166
267
  const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
167
268
  const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
@@ -192,11 +293,40 @@ function decodeJwtHeader(token) {
192
293
  return null;
193
294
  }
194
295
  }
195
- async function fetchJwks(baseUrl) {
296
+ function normalizeIssuer(url) {
297
+ return url.replace(/\/+$/, "");
298
+ }
299
+ var cachedJwks = null;
300
+ var cachedDiscovery = null;
301
+ var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
302
+ async function fetchDiscoveryDoc(baseUrl) {
303
+ const now = Date.now();
304
+ if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
305
+ return cachedDiscovery.doc;
306
+ }
196
307
  try {
197
- const res = await fetch(`${baseUrl}/api/oauth/jwks`);
308
+ const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`);
309
+ const doc = res.ok ? await res.json() : null;
310
+ cachedDiscovery = { baseUrl, doc, fetchedAt: now };
311
+ return doc;
312
+ } catch {
313
+ cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
314
+ return null;
315
+ }
316
+ }
317
+ async function fetchJwks(baseUrl, options = {}) {
318
+ const now = Date.now();
319
+ if (!options.forceRefresh && cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
320
+ return cachedJwks.jwks;
321
+ }
322
+ const discovery = await fetchDiscoveryDoc(baseUrl);
323
+ const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
324
+ try {
325
+ const res = await fetch(jwksUri, options.forceRefresh ? { cache: "no-cache" } : void 0);
198
326
  if (!res.ok) return null;
199
- return await res.json();
327
+ const jwks = await res.json();
328
+ cachedJwks = { baseUrl, jwks, fetchedAt: now };
329
+ return jwks;
200
330
  } catch {
201
331
  return null;
202
332
  }
@@ -205,7 +335,9 @@ async function verifyRs256Signature(token, jwk) {
205
335
  try {
206
336
  const [headerB64, payloadB64, signature] = token.split(".");
207
337
  if (!signature || !jwk.n || !jwk.e) return false;
208
- const cryptoKey = await crypto.subtle.importKey(
338
+ const c = getCrypto();
339
+ if (!c) return false;
340
+ const cryptoKey = await c.subtle.importKey(
209
341
  "jwk",
210
342
  { kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: false },
211
343
  { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
@@ -214,7 +346,7 @@ async function verifyRs256Signature(token, jwk) {
214
346
  );
215
347
  const signatureBytes = base64UrlDecode(signature);
216
348
  const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
217
- return await crypto.subtle.verify(
349
+ return await c.subtle.verify(
218
350
  "RSASSA-PKCS1-v1_5",
219
351
  cryptoKey,
220
352
  signatureBytes,
@@ -225,10 +357,9 @@ async function verifyRs256Signature(token, jwk) {
225
357
  }
226
358
  }
227
359
  async function computeAtHash(accessToken) {
228
- const hash = await crypto.subtle.digest(
229
- "SHA-256",
230
- new TextEncoder().encode(accessToken)
231
- );
360
+ const c = getCrypto();
361
+ if (!c) return "";
362
+ const hash = await c.subtle.digest("SHA-256", new TextEncoder().encode(accessToken));
232
363
  const bytes = new Uint8Array(hash);
233
364
  const half = bytes.slice(0, bytes.length / 2);
234
365
  let binary = "";
@@ -237,54 +368,101 @@ async function computeAtHash(accessToken) {
237
368
  }
238
369
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
239
370
  }
240
- async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId) {
371
+ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
372
+ const { rejectHs256WhenRs256Available = true } = options;
241
373
  const header = decodeJwtHeader(idToken);
242
- if (!header) throw new Error("ID Token \u683C\u5F0F\u9519\u8BEF");
374
+ if (!header) {
375
+ throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
376
+ }
243
377
  const alg = header.alg;
244
378
  if (typeof alg !== "string" || alg !== "RS256" && alg !== "HS256") {
245
- throw new Error(`\u4E0D\u652F\u6301\u7684 ID Token \u7B7E\u540D\u7B97\u6CD5: ${alg}`);
379
+ throw new SsoError("id_token_unsupported_alg", `\u4E0D\u652F\u6301\u7684 ID Token \u7B7E\u540D\u7B97\u6CD5: ${alg}`);
246
380
  }
381
+ const baseUrl = normalizeIssuer(expectedIssuer);
382
+ const discovery = await fetchDiscoveryDoc(baseUrl);
383
+ const normalizedIssuer = normalizeIssuer(discovery?.issuer || baseUrl);
247
384
  if (alg === "RS256") {
248
- const jwks = await fetchJwks(expectedIssuer);
249
- if (!jwks) throw new Error("\u65E0\u6CD5\u83B7\u53D6 JWKS");
385
+ const jwks = await fetchJwks(baseUrl);
386
+ if (!jwks) {
387
+ throw new SsoError("id_token_invalid_signature", "\u65E0\u6CD5\u83B7\u53D6 JWKS \u9A8C\u8BC1 ID Token \u7B7E\u540D");
388
+ }
250
389
  const kid = typeof header.kid === "string" ? header.kid : void 0;
251
- const key = jwks.keys.find(
390
+ const matchCandidates = (set) => set.keys.filter(
252
391
  (k) => k.kty === "RSA" && k.alg === "RS256" && k.use === "sig" && (kid ? k.kid === kid : true)
253
392
  );
254
- if (!key) throw new Error("JWKS \u4E2D\u672A\u627E\u5230\u5339\u914D\u516C\u94A5");
255
- const valid = await verifyRs256Signature(idToken, key);
256
- if (!valid) throw new Error("ID Token \u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25");
393
+ const verifyAny = async (keys) => {
394
+ for (const key of keys) {
395
+ if (await verifyRs256Signature(idToken, key)) return true;
396
+ }
397
+ return false;
398
+ };
399
+ let candidates = matchCandidates(jwks);
400
+ let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
401
+ if (candidates.length === 0 || !validSig) {
402
+ const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });
403
+ if (freshJwks) {
404
+ candidates = matchCandidates(freshJwks);
405
+ validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
406
+ }
407
+ }
408
+ if (candidates.length === 0) {
409
+ throw new SsoError("id_token_invalid_signature", "JWKS \u4E2D\u672A\u627E\u5230\u5339\u914D\u7684 RS256 \u516C\u94A5");
410
+ }
411
+ if (!validSig) {
412
+ throw new SsoError("id_token_invalid_signature", "ID Token \u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25");
413
+ }
257
414
  }
258
415
  if (alg === "HS256") {
259
- console.warn(
260
- "[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"
416
+ if (rejectHs256WhenRs256Available) {
417
+ const jwks = await fetchJwks(baseUrl);
418
+ const hasRs256 = jwks?.keys?.some((k) => k.alg === "RS256" && k.use === "sig");
419
+ if (hasRs256) {
420
+ throw new SsoError("id_token_unsupported_alg", "SSO \u4E2D\u5FC3\u5DF2\u914D\u7F6E RS256\uFF0C\u62D2\u7EDD HS256 ID Token");
421
+ }
422
+ }
423
+ throw new SsoError(
424
+ "id_token_hs256_unsupported",
425
+ "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"
261
426
  );
262
427
  }
263
428
  const payload = decodeJwtPayload(idToken);
264
- if (!payload) throw new Error("ID Token payload \u89E3\u6790\u5931\u8D25");
265
- const normalizedIssuer = expectedIssuer.replace(/\/+$/, "");
266
- const tokenIssuer = typeof payload.iss === "string" ? payload.iss.replace(/\/+$/, "") : "";
429
+ if (!payload) {
430
+ throw new SsoError("id_token_invalid", "ID Token payload \u89E3\u6790\u5931\u8D25");
431
+ }
432
+ const tokenIssuer = typeof payload.iss === "string" ? normalizeIssuer(payload.iss) : "";
267
433
  if (tokenIssuer !== normalizedIssuer) {
268
- throw new Error("ID Token issuer \u4E0D\u5339\u914D");
434
+ throw new SsoError("id_token_issuer_mismatch", "ID Token issuer \u4E0D\u5339\u914D");
269
435
  }
270
436
  const aud = payload.aud;
271
- const audArr = Array.isArray(aud) ? aud : [aud];
272
- if (!audArr.includes(expectedClientId)) {
273
- throw new Error("ID Token audience \u4E0D\u5339\u914D");
437
+ const audList = Array.isArray(aud) ? aud : typeof aud === "string" ? [aud] : [];
438
+ if (!audList.includes(expectedClientId)) {
439
+ throw new SsoError("id_token_audience_mismatch", "ID Token audience \u4E0D\u5339\u914D");
440
+ }
441
+ if (audList.length > 1 && payload.azp !== expectedClientId) {
442
+ throw new SsoError("id_token_audience_mismatch", "ID Token \u591A audience \u65F6 azp \u5FC5\u987B\u4E3A\u5F53\u524D client");
443
+ }
444
+ if (typeof payload.exp !== "number") {
445
+ throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 exp \u58F0\u660E");
446
+ }
447
+ if (Date.now() >= payload.exp * 1e3) {
448
+ throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
274
449
  }
275
- if (typeof payload.exp === "number" && Date.now() >= payload.exp * 1e3) {
276
- throw new Error("ID Token \u5DF2\u8FC7\u671F");
450
+ if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
451
+ throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
277
452
  }
278
453
  if (typeof payload.sub !== "string" || !payload.sub) {
279
- throw new Error("ID Token \u7F3A\u5C11 sub");
454
+ throw new SsoError("id_token_missing_sub", "ID Token \u7F3A\u5C11 sub");
280
455
  }
281
456
  if (typeof payload.at_hash === "string" && payload.at_hash) {
282
457
  const actual = await computeAtHash(accessToken);
283
- if (actual !== payload.at_hash) {
284
- throw new Error("ID Token at_hash \u4E0D\u5339\u914D");
458
+ if (!timingSafeEqualString(actual, payload.at_hash)) {
459
+ throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
285
460
  }
286
461
  }
462
+ return { sub: payload.sub };
287
463
  }
464
+
465
+ // src/next/callback.ts
288
466
  function createCallbackRouteHandler(config) {
289
467
  const {
290
468
  clientId,
@@ -360,7 +538,7 @@ function createCallbackRouteHandler(config) {
360
538
  if (clientSecret) {
361
539
  body.set("client_secret", clientSecret);
362
540
  }
363
- let res;
541
+ let res = null;
364
542
  let lastError;
365
543
  const maxRetries = 1;
366
544
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
@@ -435,9 +613,24 @@ function createCallbackRouteHandler(config) {
435
613
  ...getHostCookieOptions(refreshMaxAge)
436
614
  });
437
615
  if (tokenData.id_token) {
438
- response.cookies.set(idTokenCookieName, tokenData.id_token, {
439
- ...getHostCookieOptions(refreshMaxAge)
440
- });
616
+ let idTokenMaxAge = 3600;
617
+ try {
618
+ const parts = tokenData.id_token.split(".");
619
+ if (parts.length === 3) {
620
+ const payload = JSON.parse(
621
+ Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")
622
+ );
623
+ if (payload.exp && typeof payload.exp === "number") {
624
+ idTokenMaxAge = payload.exp - Math.floor(Date.now() / 1e3);
625
+ }
626
+ }
627
+ } catch {
628
+ }
629
+ if (idTokenMaxAge > 0) {
630
+ response.cookies.set(idTokenCookieName, tokenData.id_token, {
631
+ ...getHostCookieOptions(idTokenMaxAge)
632
+ });
633
+ }
441
634
  }
442
635
  response.cookies.set(stateCookieName, "", getHostCookieOptions(0));
443
636
  response.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0));
@@ -450,9 +643,9 @@ function createCallbackRouteHandler(config) {
450
643
  // src/next/logout.ts
451
644
  import { NextResponse as NextResponse3 } from "next/server";
452
645
  async function fetchDiscovery(ssoBaseUrl) {
646
+ const controller = new AbortController();
647
+ const timeoutId = setTimeout(() => controller.abort(), 5e3);
453
648
  try {
454
- const controller = new AbortController();
455
- setTimeout(() => controller.abort(), 5e3);
456
649
  const res = await fetch(
457
650
  `${ssoBaseUrl}/api/oauth/.well-known/openid-configuration`,
458
651
  { signal: controller.signal }
@@ -461,8 +654,24 @@ async function fetchDiscovery(ssoBaseUrl) {
461
654
  return await res.json();
462
655
  } catch {
463
656
  return null;
657
+ } finally {
658
+ clearTimeout(timeoutId);
464
659
  }
465
660
  }
661
+ function generateRandomString2(length) {
662
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
663
+ const maxValid = Math.floor(256 / chars.length) * chars.length;
664
+ let result = "";
665
+ while (result.length < length) {
666
+ const array = new Uint8Array(length * 2);
667
+ crypto.getRandomValues(array);
668
+ for (let i = 0; i < array.length && result.length < length; i++) {
669
+ if (array[i] >= maxValid) continue;
670
+ result += chars[array[i] % chars.length];
671
+ }
672
+ }
673
+ return result;
674
+ }
466
675
  function createLogoutRouteHandler(config) {
467
676
  const {
468
677
  clientId,
@@ -477,10 +686,24 @@ function createLogoutRouteHandler(config) {
477
686
  stateCookieName = DEFAULT_STATE_COOKIE_NAME,
478
687
  returnUrlCookieName = DEFAULT_RETURN_COOKIE_NAME,
479
688
  verifierCookieName = DEFAULT_VERIFIER_COOKIE_NAME,
480
- callbackPath = "/api/auth/callback"
689
+ callbackPath = "/api/auth/callback",
690
+ logoutStateCookieName = DEFAULT_LOGOUT_STATE_COOKIE_NAME
481
691
  } = config;
482
692
  const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
483
- return async function GET(request) {
693
+ return async function handler(request) {
694
+ const returnedState = request.nextUrl.searchParams.get("state");
695
+ if (returnedState) {
696
+ const savedState = request.cookies.get(logoutStateCookieName)?.value;
697
+ if (!savedState || savedState !== returnedState) {
698
+ return NextResponse3.json(
699
+ { error: "invalid_request", error_description: "Logout state \u4E0D\u5339\u914D" },
700
+ { status: 400 }
701
+ );
702
+ }
703
+ const res = NextResponse3.redirect(request.nextUrl.origin + "/");
704
+ res.cookies.set(logoutStateCookieName, "", getHostCookieOptions(0));
705
+ return res;
706
+ }
484
707
  const refreshToken = request.cookies.get(refreshTokenCookieName)?.value;
485
708
  const idTokenHint = request.cookies.get(idTokenCookieName)?.value;
486
709
  if (refreshToken) {
@@ -525,7 +748,11 @@ function createLogoutRouteHandler(config) {
525
748
  if (idTokenHint) {
526
749
  logoutUrl.searchParams.set("id_token_hint", idTokenHint);
527
750
  }
528
- return clearCookies(NextResponse3.redirect(logoutUrl.toString()));
751
+ const logoutState = generateRandomString2(32);
752
+ logoutUrl.searchParams.set("state", logoutState);
753
+ const res = clearCookies(NextResponse3.redirect(logoutUrl.toString()));
754
+ res.cookies.set(logoutStateCookieName, logoutState, getHostCookieOptions(600));
755
+ return res;
529
756
  }
530
757
  return clearCookies(
531
758
  NextResponse3.redirect(request.nextUrl.origin + "/")
@@ -534,6 +761,8 @@ function createLogoutRouteHandler(config) {
534
761
  }
535
762
  export {
536
763
  DEFAULT_ACCESS_TOKEN_COOKIE_NAME,
764
+ DEFAULT_ID_TOKEN_COOKIE_NAME,
765
+ DEFAULT_LOGOUT_STATE_COOKIE_NAME,
537
766
  DEFAULT_REFRESH_TOKEN_COOKIE_NAME,
538
767
  DEFAULT_RETURN_COOKIE_NAME,
539
768
  DEFAULT_STATE_COOKIE_NAME,