@nihplod/sso-sdk 1.0.1 → 1.2.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,
@@ -29,7 +31,8 @@ __export(next_exports, {
29
31
  createLogoutRouteHandler: () => createLogoutRouteHandler,
30
32
  createSsoMiddleware: () => createSsoMiddleware,
31
33
  getHostCookieOptions: () => getHostCookieOptions,
32
- getSecureCookieOptions: () => getSecureCookieOptions
34
+ getSecureCookieOptions: () => getSecureCookieOptions,
35
+ toInsecureCookieName: () => toInsecureCookieName
33
36
  });
34
37
  module.exports = __toCommonJS(next_exports);
35
38
 
@@ -43,19 +46,36 @@ var DEFAULT_ID_TOKEN_COOKIE_NAME = "__Host-nihplod_sso_id";
43
46
  var DEFAULT_STATE_COOKIE_NAME = "__Host-nihplod_sso_state";
44
47
  var DEFAULT_RETURN_COOKIE_NAME = "__Host-nihplod_sso_return";
45
48
  var DEFAULT_VERIFIER_COOKIE_NAME = "__Secure-nihplod_sso_verifier";
46
- function getHostCookieOptions(maxAge) {
49
+ var DEFAULT_LOGOUT_STATE_COOKIE_NAME = "__Host-nihplod_sso_logout_state";
50
+ function toInsecureCookieName(name) {
51
+ return name.replace(/^__(Host|Secure)-/, "");
52
+ }
53
+ function resolveInsecureLocalDev(enabled, ssoBaseUrl) {
54
+ if (!enabled) return false;
55
+ if (process.env.NODE_ENV === "production" && /^https:\/\//i.test(ssoBaseUrl)) {
56
+ console.warn(
57
+ "[SSO SDK] insecureLocalDev=true \u5DF2\u88AB\u5FFD\u7565\uFF1A\u5F53\u524D\u4E3A\u751F\u4EA7\u73AF\u5883\uFF08NODE_ENV=production\uFF09\u4E14 ssoBaseUrl \u4F7F\u7528 HTTPS\uFF0CCookie \u4ECD\u4FDD\u6301 Secure \u5C5E\u6027\u4E0E __Host-/__Secure- \u524D\u7F00\u3002\u8BF7\u4ECE\u751F\u4EA7\u914D\u7F6E\u4E2D\u79FB\u9664\u8BE5\u5F00\u5173\u3002"
58
+ );
59
+ return false;
60
+ }
61
+ console.warn(
62
+ "[SSO SDK] insecureLocalDev=true\uFF1ACookie \u7684 Secure \u5C5E\u6027\u5DF2\u5173\u95ED\u4E14\u53BB\u9664 __Host-/__Secure- \u524D\u7F00\u3002\u4EC5\u9650 http://localhost \u672C\u5730\u5F00\u53D1\u4F7F\u7528\uFF0C\u751F\u4EA7\u73AF\u5883\u5FC5\u987B\u79FB\u9664\u8BE5\u914D\u7F6E\uFF08\u751F\u4EA7\u5FC5\u987B\u7528 HTTPS\uFF09\u3002"
63
+ );
64
+ return true;
65
+ }
66
+ function getHostCookieOptions(maxAge, secure = true) {
47
67
  return {
48
68
  httpOnly: true,
49
- secure: true,
69
+ secure,
50
70
  sameSite: "lax",
51
71
  path: "/",
52
72
  ...maxAge !== void 0 ? { maxAge } : {}
53
73
  };
54
74
  }
55
- function getSecureCookieOptions(maxAge, path = "/") {
75
+ function getSecureCookieOptions(maxAge, path = "/", secure = true) {
56
76
  return {
57
77
  httpOnly: true,
58
- secure: true,
78
+ secure,
59
79
  sameSite: "lax",
60
80
  path,
61
81
  ...maxAge !== void 0 ? { maxAge } : {}
@@ -96,7 +116,43 @@ function matchesPath(pathname, paths) {
96
116
  return false;
97
117
  });
98
118
  }
119
+ var introspectionCache = /* @__PURE__ */ new Map();
120
+ var INTROSPECT_CACHE_TTL_MS = 3e4;
121
+ var INTROSPECT_CACHE_MAX_ENTRIES = 500;
122
+ async function introspectCacheKey(token, clientId) {
123
+ const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
124
+ const hex = Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
125
+ return `${hex}|${clientId}`;
126
+ }
127
+ function introspectCacheGet(key) {
128
+ const entry = introspectionCache.get(key);
129
+ if (!entry) return null;
130
+ if (entry.until <= Date.now()) {
131
+ introspectionCache.delete(key);
132
+ return null;
133
+ }
134
+ introspectionCache.delete(key);
135
+ introspectionCache.set(key, entry);
136
+ return entry;
137
+ }
138
+ function introspectCacheSet(key, active) {
139
+ const now = Date.now();
140
+ for (const [k, v] of introspectionCache) {
141
+ if (v.until <= now) introspectionCache.delete(k);
142
+ }
143
+ while (introspectionCache.size >= INTROSPECT_CACHE_MAX_ENTRIES) {
144
+ const oldest = introspectionCache.keys().next();
145
+ if (oldest.done) break;
146
+ introspectionCache.delete(oldest.value);
147
+ }
148
+ introspectionCache.set(key, { active, until: now + INTROSPECT_CACHE_TTL_MS });
149
+ }
99
150
  async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret) {
151
+ const cacheKey = await introspectCacheKey(token, clientId);
152
+ const cached = introspectCacheGet(cacheKey);
153
+ if (cached) {
154
+ return cached.active;
155
+ }
100
156
  try {
101
157
  const body = new URLSearchParams({
102
158
  token,
@@ -111,9 +167,17 @@ async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret)
111
167
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
112
168
  body: body.toString()
113
169
  });
114
- if (!res.ok) return false;
170
+ if (!res.ok) {
171
+ if (res.status !== 401 && res.status !== 403) {
172
+ return false;
173
+ }
174
+ introspectCacheSet(cacheKey, false);
175
+ return false;
176
+ }
115
177
  const data = await res.json();
116
- return data.active === true;
178
+ const active = data.active === true;
179
+ introspectCacheSet(cacheKey, active);
180
+ return active;
117
181
  } catch {
118
182
  return false;
119
183
  }
@@ -128,12 +192,28 @@ function createSsoMiddleware(config) {
128
192
  publicPaths = [],
129
193
  callbackPath = "/api/auth/callback",
130
194
  ssoCookieName = "__Host-user_token",
131
- accessTokenCookieName = DEFAULT_ACCESS_TOKEN_COOKIE_NAME,
132
- stateCookieName = DEFAULT_STATE_COOKIE_NAME,
133
- returnUrlCookieName = DEFAULT_RETURN_COOKIE_NAME,
134
- verifierCookieName = DEFAULT_VERIFIER_COOKIE_NAME
195
+ validateSsoCookie = true,
196
+ insecureLocalDev: insecureLocalDevOpt = false
135
197
  } = config;
198
+ const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
199
+ const secureCookies = !insecureLocalDev;
200
+ const accessTokenCookieName = insecureLocalDev ? toInsecureCookieName(config.accessTokenCookieName ?? DEFAULT_ACCESS_TOKEN_COOKIE_NAME) : config.accessTokenCookieName ?? DEFAULT_ACCESS_TOKEN_COOKIE_NAME;
201
+ const stateCookieName = insecureLocalDev ? toInsecureCookieName(config.stateCookieName ?? DEFAULT_STATE_COOKIE_NAME) : config.stateCookieName ?? DEFAULT_STATE_COOKIE_NAME;
202
+ const returnUrlCookieName = insecureLocalDev ? toInsecureCookieName(config.returnUrlCookieName ?? DEFAULT_RETURN_COOKIE_NAME) : config.returnUrlCookieName ?? DEFAULT_RETURN_COOKIE_NAME;
203
+ const verifierCookieName = insecureLocalDev ? toInsecureCookieName(config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME) : config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME;
136
204
  const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
205
+ if (process.env.NODE_ENV !== "production") {
206
+ if (!validateSsoCookie) {
207
+ console.warn(
208
+ "[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"
209
+ );
210
+ }
211
+ if (!clientSecret) {
212
+ console.warn(
213
+ "[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"
214
+ );
215
+ }
216
+ }
137
217
  return async function ssoMiddleware(request) {
138
218
  const { pathname } = request.nextUrl;
139
219
  if (pathname.startsWith("/_next/") || pathname.startsWith("/favicon.ico") || pathname.match(/\.(ico|png|jpg|jpeg|svg|css|js|woff2?)$/)) {
@@ -148,7 +228,19 @@ function createSsoMiddleware(config) {
148
228
  }
149
229
  const ssoSession = request.cookies.get(ssoCookieName);
150
230
  if (ssoSession?.value) {
151
- return import_server.NextResponse.next();
231
+ if (validateSsoCookie) {
232
+ const tokenActive = await introspectAccessToken(
233
+ ssoSession.value,
234
+ normalizedBase,
235
+ clientId,
236
+ clientSecret
237
+ );
238
+ if (tokenActive) {
239
+ return import_server.NextResponse.next();
240
+ }
241
+ } else {
242
+ return import_server.NextResponse.next();
243
+ }
152
244
  }
153
245
  const accessTokenCookie = request.cookies.get(accessTokenCookieName);
154
246
  if (accessTokenCookie?.value) {
@@ -176,11 +268,12 @@ function createSsoMiddleware(config) {
176
268
  const loginUrl = new URL("/api/oauth/authorize", normalizedBase);
177
269
  loginUrl.search = authorizeParams.toString();
178
270
  const response = import_server.NextResponse.redirect(loginUrl);
179
- response.cookies.set(stateCookieName, state, getHostCookieOptions(600));
180
- response.cookies.set(verifierCookieName, verifier, getSecureCookieOptions(600, callbackPath));
181
- response.cookies.set(returnUrlCookieName, request.url, getHostCookieOptions(600));
271
+ response.cookies.set(stateCookieName, state, getHostCookieOptions(600, secureCookies));
272
+ response.cookies.set(verifierCookieName, verifier, getSecureCookieOptions(600, callbackPath, secureCookies));
273
+ const safeReturnUrl = (request.nextUrl.pathname + request.nextUrl.search).slice(0, 2048);
274
+ response.cookies.set(returnUrlCookieName, safeReturnUrl, getHostCookieOptions(600, secureCookies));
182
275
  if (accessTokenCookie?.value) {
183
- response.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0));
276
+ response.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0, secureCookies));
184
277
  }
185
278
  return response;
186
279
  };
@@ -188,8 +281,22 @@ function createSsoMiddleware(config) {
188
281
 
189
282
  // src/next/callback.ts
190
283
  var import_server2 = require("next/server");
284
+
285
+ // src/core/errors.ts
286
+ var SsoError = class extends Error {
287
+ constructor(code, description, cause) {
288
+ super(`[SSO SDK] ${code}: ${description}`);
289
+ this.name = "SsoError";
290
+ this.code = code;
291
+ this.description = description;
292
+ this.cause = cause;
293
+ }
294
+ };
295
+
296
+ // src/core/security.ts
191
297
  function isTrustedReturnUrl(url, currentOrigin) {
192
298
  if (!url) return false;
299
+ if (url.includes("\\")) return false;
193
300
  if (url.startsWith("/") && !url.startsWith("//")) return true;
194
301
  try {
195
302
  return new URL(url).origin === currentOrigin;
@@ -197,6 +304,23 @@ function isTrustedReturnUrl(url, currentOrigin) {
197
304
  return false;
198
305
  }
199
306
  }
307
+ function timingSafeEqualString(a, b) {
308
+ const ba = new TextEncoder().encode(a);
309
+ const bb = new TextEncoder().encode(b);
310
+ if (ba.length === 0 || bb.length === 0) return ba.length === bb.length;
311
+ const len = Math.max(ba.length, bb.length);
312
+ let diff = ba.length ^ bb.length;
313
+ for (let i = 0; i < len; i++) {
314
+ diff |= ba[i % ba.length] ^ bb[i % bb.length];
315
+ }
316
+ return diff === 0;
317
+ }
318
+
319
+ // src/core/id-token.ts
320
+ var _crypto = typeof crypto !== "undefined" && crypto.subtle ? crypto : null;
321
+ function getCrypto() {
322
+ return _crypto ?? null;
323
+ }
200
324
  function base64UrlDecode(input) {
201
325
  const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
202
326
  const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
@@ -227,11 +351,40 @@ function decodeJwtHeader(token) {
227
351
  return null;
228
352
  }
229
353
  }
230
- async function fetchJwks(baseUrl) {
354
+ function normalizeIssuer(url) {
355
+ return url.replace(/\/+$/, "");
356
+ }
357
+ var cachedJwks = null;
358
+ var cachedDiscovery = null;
359
+ var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
360
+ async function fetchDiscoveryDoc(baseUrl) {
361
+ const now = Date.now();
362
+ if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
363
+ return cachedDiscovery.doc;
364
+ }
365
+ try {
366
+ const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`);
367
+ const doc = res.ok ? await res.json() : null;
368
+ cachedDiscovery = { baseUrl, doc, fetchedAt: now };
369
+ return doc;
370
+ } catch {
371
+ cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
372
+ return null;
373
+ }
374
+ }
375
+ async function fetchJwks(baseUrl, options = {}) {
376
+ const now = Date.now();
377
+ if (!options.forceRefresh && cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
378
+ return cachedJwks.jwks;
379
+ }
380
+ const discovery = await fetchDiscoveryDoc(baseUrl);
381
+ const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
231
382
  try {
232
- const res = await fetch(`${baseUrl}/api/oauth/jwks`);
383
+ const res = await fetch(jwksUri, options.forceRefresh ? { cache: "no-cache" } : void 0);
233
384
  if (!res.ok) return null;
234
- return await res.json();
385
+ const jwks = await res.json();
386
+ cachedJwks = { baseUrl, jwks, fetchedAt: now };
387
+ return jwks;
235
388
  } catch {
236
389
  return null;
237
390
  }
@@ -240,7 +393,9 @@ async function verifyRs256Signature(token, jwk) {
240
393
  try {
241
394
  const [headerB64, payloadB64, signature] = token.split(".");
242
395
  if (!signature || !jwk.n || !jwk.e) return false;
243
- const cryptoKey = await crypto.subtle.importKey(
396
+ const c = getCrypto();
397
+ if (!c) return false;
398
+ const cryptoKey = await c.subtle.importKey(
244
399
  "jwk",
245
400
  { kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: false },
246
401
  { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
@@ -249,7 +404,7 @@ async function verifyRs256Signature(token, jwk) {
249
404
  );
250
405
  const signatureBytes = base64UrlDecode(signature);
251
406
  const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
252
- return await crypto.subtle.verify(
407
+ return await c.subtle.verify(
253
408
  "RSASSA-PKCS1-v1_5",
254
409
  cryptoKey,
255
410
  signatureBytes,
@@ -260,10 +415,9 @@ async function verifyRs256Signature(token, jwk) {
260
415
  }
261
416
  }
262
417
  async function computeAtHash(accessToken) {
263
- const hash = await crypto.subtle.digest(
264
- "SHA-256",
265
- new TextEncoder().encode(accessToken)
266
- );
418
+ const c = getCrypto();
419
+ if (!c) return "";
420
+ const hash = await c.subtle.digest("SHA-256", new TextEncoder().encode(accessToken));
267
421
  const bytes = new Uint8Array(hash);
268
422
  const half = bytes.slice(0, bytes.length / 2);
269
423
  let binary = "";
@@ -272,54 +426,101 @@ async function computeAtHash(accessToken) {
272
426
  }
273
427
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
274
428
  }
275
- async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId) {
429
+ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
430
+ const { rejectHs256WhenRs256Available = true } = options;
276
431
  const header = decodeJwtHeader(idToken);
277
- if (!header) throw new Error("ID Token \u683C\u5F0F\u9519\u8BEF");
432
+ if (!header) {
433
+ throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
434
+ }
278
435
  const alg = header.alg;
279
436
  if (typeof alg !== "string" || alg !== "RS256" && alg !== "HS256") {
280
- throw new Error(`\u4E0D\u652F\u6301\u7684 ID Token \u7B7E\u540D\u7B97\u6CD5: ${alg}`);
437
+ throw new SsoError("id_token_unsupported_alg", `\u4E0D\u652F\u6301\u7684 ID Token \u7B7E\u540D\u7B97\u6CD5: ${alg}`);
281
438
  }
439
+ const baseUrl = normalizeIssuer(expectedIssuer);
440
+ const discovery = await fetchDiscoveryDoc(baseUrl);
441
+ const normalizedIssuer = normalizeIssuer(discovery?.issuer || baseUrl);
282
442
  if (alg === "RS256") {
283
- const jwks = await fetchJwks(expectedIssuer);
284
- if (!jwks) throw new Error("\u65E0\u6CD5\u83B7\u53D6 JWKS");
443
+ const jwks = await fetchJwks(baseUrl);
444
+ if (!jwks) {
445
+ throw new SsoError("id_token_invalid_signature", "\u65E0\u6CD5\u83B7\u53D6 JWKS \u9A8C\u8BC1 ID Token \u7B7E\u540D");
446
+ }
285
447
  const kid = typeof header.kid === "string" ? header.kid : void 0;
286
- const key = jwks.keys.find(
448
+ const matchCandidates = (set) => set.keys.filter(
287
449
  (k) => k.kty === "RSA" && k.alg === "RS256" && k.use === "sig" && (kid ? k.kid === kid : true)
288
450
  );
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");
451
+ const verifyAny = async (keys) => {
452
+ for (const key of keys) {
453
+ if (await verifyRs256Signature(idToken, key)) return true;
454
+ }
455
+ return false;
456
+ };
457
+ let candidates = matchCandidates(jwks);
458
+ let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
459
+ if (candidates.length === 0 || !validSig) {
460
+ const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });
461
+ if (freshJwks) {
462
+ candidates = matchCandidates(freshJwks);
463
+ validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
464
+ }
465
+ }
466
+ if (candidates.length === 0) {
467
+ throw new SsoError("id_token_invalid_signature", "JWKS \u4E2D\u672A\u627E\u5230\u5339\u914D\u7684 RS256 \u516C\u94A5");
468
+ }
469
+ if (!validSig) {
470
+ throw new SsoError("id_token_invalid_signature", "ID Token \u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25");
471
+ }
292
472
  }
293
473
  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"
474
+ if (rejectHs256WhenRs256Available) {
475
+ const jwks = await fetchJwks(baseUrl);
476
+ const hasRs256 = jwks?.keys?.some((k) => k.alg === "RS256" && k.use === "sig");
477
+ if (hasRs256) {
478
+ throw new SsoError("id_token_unsupported_alg", "SSO \u4E2D\u5FC3\u5DF2\u914D\u7F6E RS256\uFF0C\u62D2\u7EDD HS256 ID Token");
479
+ }
480
+ }
481
+ throw new SsoError(
482
+ "id_token_hs256_unsupported",
483
+ "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
484
  );
297
485
  }
298
486
  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(/\/+$/, "") : "";
487
+ if (!payload) {
488
+ throw new SsoError("id_token_invalid", "ID Token payload \u89E3\u6790\u5931\u8D25");
489
+ }
490
+ const tokenIssuer = typeof payload.iss === "string" ? normalizeIssuer(payload.iss) : "";
302
491
  if (tokenIssuer !== normalizedIssuer) {
303
- throw new Error("ID Token issuer \u4E0D\u5339\u914D");
492
+ throw new SsoError("id_token_issuer_mismatch", "ID Token issuer \u4E0D\u5339\u914D");
304
493
  }
305
494
  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");
495
+ const audList = Array.isArray(aud) ? aud : typeof aud === "string" ? [aud] : [];
496
+ if (!audList.includes(expectedClientId)) {
497
+ throw new SsoError("id_token_audience_mismatch", "ID Token audience \u4E0D\u5339\u914D");
498
+ }
499
+ if (audList.length > 1 && payload.azp !== expectedClientId) {
500
+ throw new SsoError("id_token_audience_mismatch", "ID Token \u591A audience \u65F6 azp \u5FC5\u987B\u4E3A\u5F53\u524D client");
309
501
  }
310
- if (typeof payload.exp === "number" && Date.now() >= payload.exp * 1e3) {
311
- throw new Error("ID Token \u5DF2\u8FC7\u671F");
502
+ if (typeof payload.exp !== "number") {
503
+ throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 exp \u58F0\u660E");
504
+ }
505
+ if (Date.now() >= payload.exp * 1e3 + 6e4) {
506
+ throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
507
+ }
508
+ if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
509
+ throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
312
510
  }
313
511
  if (typeof payload.sub !== "string" || !payload.sub) {
314
- throw new Error("ID Token \u7F3A\u5C11 sub");
512
+ throw new SsoError("id_token_missing_sub", "ID Token \u7F3A\u5C11 sub");
315
513
  }
316
514
  if (typeof payload.at_hash === "string" && payload.at_hash) {
317
515
  const actual = await computeAtHash(accessToken);
318
- if (actual !== payload.at_hash) {
319
- throw new Error("ID Token at_hash \u4E0D\u5339\u914D");
516
+ if (!timingSafeEqualString(actual, payload.at_hash)) {
517
+ throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
320
518
  }
321
519
  }
520
+ return { sub: payload.sub };
322
521
  }
522
+
523
+ // src/next/callback.ts
323
524
  function createCallbackRouteHandler(config) {
324
525
  const {
325
526
  clientId,
@@ -327,13 +528,17 @@ function createCallbackRouteHandler(config) {
327
528
  redirectUri,
328
529
  clientSecret,
329
530
  defaultReturnPath = "/",
330
- accessTokenCookieName = DEFAULT_ACCESS_TOKEN_COOKIE_NAME,
331
- refreshTokenCookieName = DEFAULT_REFRESH_TOKEN_COOKIE_NAME,
332
- idTokenCookieName = DEFAULT_ID_TOKEN_COOKIE_NAME,
333
- stateCookieName = DEFAULT_STATE_COOKIE_NAME,
334
- returnUrlCookieName = DEFAULT_RETURN_COOKIE_NAME,
335
- verifierCookieName = DEFAULT_VERIFIER_COOKIE_NAME
531
+ insecureLocalDev: insecureLocalDevOpt = false
336
532
  } = config;
533
+ const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
534
+ const secureCookies = !insecureLocalDev;
535
+ const pickName = (explicit, fallback) => insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : explicit ?? fallback;
536
+ const accessTokenCookieName = pickName(config.accessTokenCookieName, DEFAULT_ACCESS_TOKEN_COOKIE_NAME);
537
+ const refreshTokenCookieName = pickName(config.refreshTokenCookieName, DEFAULT_REFRESH_TOKEN_COOKIE_NAME);
538
+ const idTokenCookieName = pickName(config.idTokenCookieName, DEFAULT_ID_TOKEN_COOKIE_NAME);
539
+ const stateCookieName = pickName(config.stateCookieName, DEFAULT_STATE_COOKIE_NAME);
540
+ const returnUrlCookieName = pickName(config.returnUrlCookieName, DEFAULT_RETURN_COOKIE_NAME);
541
+ const verifierCookieName = pickName(config.verifierCookieName, DEFAULT_VERIFIER_COOKIE_NAME);
337
542
  const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
338
543
  return async function GET(request) {
339
544
  const { searchParams } = request.nextUrl;
@@ -395,7 +600,7 @@ function createCallbackRouteHandler(config) {
395
600
  if (clientSecret) {
396
601
  body.set("client_secret", clientSecret);
397
602
  }
398
- let res;
603
+ let res = null;
399
604
  let lastError;
400
605
  const maxRetries = 1;
401
606
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
@@ -441,6 +646,15 @@ function createCallbackRouteHandler(config) {
441
646
  );
442
647
  }
443
648
  const tokenData = await res.json();
649
+ if (!tokenData.access_token || !tokenData.refresh_token) {
650
+ return import_server2.NextResponse.json(
651
+ {
652
+ error: "server_error",
653
+ error_description: "Token \u54CD\u5E94\u7F3A\u5C11 access_token \u6216 refresh_token"
654
+ },
655
+ { status: 502 }
656
+ );
657
+ }
444
658
  if (tokenData.id_token) {
445
659
  try {
446
660
  await validateIdToken(
@@ -463,21 +677,36 @@ function createCallbackRouteHandler(config) {
463
677
  const returnUrl = isTrustedReturnUrl(rawReturnUrl, request.nextUrl.origin) ? rawReturnUrl : "/";
464
678
  const response = import_server2.NextResponse.redirect(new URL(returnUrl, request.url));
465
679
  response.cookies.set(accessTokenCookieName, tokenData.access_token, {
466
- ...getHostCookieOptions(tokenData.expires_in)
680
+ ...getHostCookieOptions(tokenData.expires_in, secureCookies)
467
681
  });
468
682
  const refreshMaxAge = tokenData.refresh_expires_in != null ? tokenData.refresh_expires_in : 30 * 24 * 60 * 60;
469
683
  response.cookies.set(refreshTokenCookieName, tokenData.refresh_token, {
470
- ...getHostCookieOptions(refreshMaxAge)
684
+ ...getHostCookieOptions(refreshMaxAge, secureCookies)
471
685
  });
472
686
  if (tokenData.id_token) {
473
- response.cookies.set(idTokenCookieName, tokenData.id_token, {
474
- ...getHostCookieOptions(refreshMaxAge)
475
- });
476
- }
477
- response.cookies.set(stateCookieName, "", getHostCookieOptions(0));
478
- response.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0));
479
- response.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/"));
480
- response.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, request.nextUrl.pathname));
687
+ let idTokenMaxAge = 3600;
688
+ try {
689
+ const parts = tokenData.id_token.split(".");
690
+ if (parts.length === 3) {
691
+ const payload = JSON.parse(
692
+ Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")
693
+ );
694
+ if (payload.exp && typeof payload.exp === "number") {
695
+ idTokenMaxAge = payload.exp - Math.floor(Date.now() / 1e3);
696
+ }
697
+ }
698
+ } catch {
699
+ }
700
+ if (idTokenMaxAge > 0) {
701
+ response.cookies.set(idTokenCookieName, tokenData.id_token, {
702
+ ...getHostCookieOptions(idTokenMaxAge, secureCookies)
703
+ });
704
+ }
705
+ }
706
+ response.cookies.set(stateCookieName, "", getHostCookieOptions(0, secureCookies));
707
+ response.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0, secureCookies));
708
+ response.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/", secureCookies));
709
+ response.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, request.nextUrl.pathname, secureCookies));
481
710
  return response;
482
711
  };
483
712
  }
@@ -485,9 +714,9 @@ function createCallbackRouteHandler(config) {
485
714
  // src/next/logout.ts
486
715
  var import_server3 = require("next/server");
487
716
  async function fetchDiscovery(ssoBaseUrl) {
717
+ const controller = new AbortController();
718
+ const timeoutId = setTimeout(() => controller.abort(), 5e3);
488
719
  try {
489
- const controller = new AbortController();
490
- setTimeout(() => controller.abort(), 5e3);
491
720
  const res = await fetch(
492
721
  `${ssoBaseUrl}/api/oauth/.well-known/openid-configuration`,
493
722
  { signal: controller.signal }
@@ -496,7 +725,23 @@ async function fetchDiscovery(ssoBaseUrl) {
496
725
  return await res.json();
497
726
  } catch {
498
727
  return null;
728
+ } finally {
729
+ clearTimeout(timeoutId);
730
+ }
731
+ }
732
+ function generateRandomString2(length) {
733
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
734
+ const maxValid = Math.floor(256 / chars.length) * chars.length;
735
+ let result = "";
736
+ while (result.length < length) {
737
+ const array = new Uint8Array(length * 2);
738
+ crypto.getRandomValues(array);
739
+ for (let i = 0; i < array.length && result.length < length; i++) {
740
+ if (array[i] >= maxValid) continue;
741
+ result += chars[array[i] % chars.length];
742
+ }
499
743
  }
744
+ return result;
500
745
  }
501
746
  function createLogoutRouteHandler(config) {
502
747
  const {
@@ -506,16 +751,34 @@ function createLogoutRouteHandler(config) {
506
751
  clientSecret,
507
752
  postLogoutRedirectUri = new URL(redirectUri).origin + "/",
508
753
  redirectToSso = true,
509
- accessTokenCookieName = DEFAULT_ACCESS_TOKEN_COOKIE_NAME,
510
- refreshTokenCookieName = DEFAULT_REFRESH_TOKEN_COOKIE_NAME,
511
- idTokenCookieName = DEFAULT_ID_TOKEN_COOKIE_NAME,
512
- stateCookieName = DEFAULT_STATE_COOKIE_NAME,
513
- returnUrlCookieName = DEFAULT_RETURN_COOKIE_NAME,
514
- verifierCookieName = DEFAULT_VERIFIER_COOKIE_NAME,
515
- callbackPath = "/api/auth/callback"
754
+ callbackPath = "/api/auth/callback",
755
+ insecureLocalDev: insecureLocalDevOpt = false
516
756
  } = config;
757
+ const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
758
+ const secureCookies = !insecureLocalDev;
759
+ const pickName = (explicit, fallback) => insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : explicit ?? fallback;
760
+ const accessTokenCookieName = pickName(config.accessTokenCookieName, DEFAULT_ACCESS_TOKEN_COOKIE_NAME);
761
+ const refreshTokenCookieName = pickName(config.refreshTokenCookieName, DEFAULT_REFRESH_TOKEN_COOKIE_NAME);
762
+ const idTokenCookieName = pickName(config.idTokenCookieName, DEFAULT_ID_TOKEN_COOKIE_NAME);
763
+ const stateCookieName = pickName(config.stateCookieName, DEFAULT_STATE_COOKIE_NAME);
764
+ const returnUrlCookieName = pickName(config.returnUrlCookieName, DEFAULT_RETURN_COOKIE_NAME);
765
+ const verifierCookieName = pickName(config.verifierCookieName, DEFAULT_VERIFIER_COOKIE_NAME);
766
+ const logoutStateCookieName = pickName(config.logoutStateCookieName, DEFAULT_LOGOUT_STATE_COOKIE_NAME);
517
767
  const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
518
- return async function GET(request) {
768
+ return async function handler(request) {
769
+ const returnedState = request.nextUrl.searchParams.get("state");
770
+ if (returnedState) {
771
+ const savedState = request.cookies.get(logoutStateCookieName)?.value;
772
+ if (!savedState || savedState !== returnedState) {
773
+ return import_server3.NextResponse.json(
774
+ { error: "invalid_request", error_description: "Logout state \u4E0D\u5339\u914D" },
775
+ { status: 400 }
776
+ );
777
+ }
778
+ const res = import_server3.NextResponse.redirect(request.nextUrl.origin + "/");
779
+ res.cookies.set(logoutStateCookieName, "", getHostCookieOptions(0, secureCookies));
780
+ return res;
781
+ }
519
782
  const refreshToken = request.cookies.get(refreshTokenCookieName)?.value;
520
783
  const idTokenHint = request.cookies.get(idTokenCookieName)?.value;
521
784
  if (refreshToken) {
@@ -539,18 +802,18 @@ function createLogoutRouteHandler(config) {
539
802
  }
540
803
  }
541
804
  const clearCookies = (res) => {
542
- res.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0));
543
- res.cookies.set(refreshTokenCookieName, "", getHostCookieOptions(0));
544
- res.cookies.set(idTokenCookieName, "", getHostCookieOptions(0));
545
- res.cookies.set(stateCookieName, "", getHostCookieOptions(0));
546
- res.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0));
547
- res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/"));
548
- res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, callbackPath));
805
+ res.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0, secureCookies));
806
+ res.cookies.set(refreshTokenCookieName, "", getHostCookieOptions(0, secureCookies));
807
+ res.cookies.set(idTokenCookieName, "", getHostCookieOptions(0, secureCookies));
808
+ res.cookies.set(stateCookieName, "", getHostCookieOptions(0, secureCookies));
809
+ res.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0, secureCookies));
810
+ res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/", secureCookies));
811
+ res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, callbackPath, secureCookies));
549
812
  return res;
550
813
  };
551
814
  if (redirectToSso) {
552
815
  const discovery = await fetchDiscovery(normalizedBase);
553
- const endSessionEndpoint = discovery?.end_session_endpoint || `${normalizedBase}/logout`;
816
+ const endSessionEndpoint = discovery?.end_session_endpoint || `${normalizedBase}/api/oauth/end-session`;
554
817
  const logoutUrl = new URL(endSessionEndpoint);
555
818
  logoutUrl.searchParams.set("client_id", clientId);
556
819
  logoutUrl.searchParams.set(
@@ -560,7 +823,11 @@ function createLogoutRouteHandler(config) {
560
823
  if (idTokenHint) {
561
824
  logoutUrl.searchParams.set("id_token_hint", idTokenHint);
562
825
  }
563
- return clearCookies(import_server3.NextResponse.redirect(logoutUrl.toString()));
826
+ const logoutState = generateRandomString2(32);
827
+ logoutUrl.searchParams.set("state", logoutState);
828
+ const res = clearCookies(import_server3.NextResponse.redirect(logoutUrl.toString()));
829
+ res.cookies.set(logoutStateCookieName, logoutState, getHostCookieOptions(600, secureCookies));
830
+ return res;
564
831
  }
565
832
  return clearCookies(
566
833
  import_server3.NextResponse.redirect(request.nextUrl.origin + "/")
@@ -570,6 +837,8 @@ function createLogoutRouteHandler(config) {
570
837
  // Annotate the CommonJS export names for ESM import in node:
571
838
  0 && (module.exports = {
572
839
  DEFAULT_ACCESS_TOKEN_COOKIE_NAME,
840
+ DEFAULT_ID_TOKEN_COOKIE_NAME,
841
+ DEFAULT_LOGOUT_STATE_COOKIE_NAME,
573
842
  DEFAULT_REFRESH_TOKEN_COOKIE_NAME,
574
843
  DEFAULT_RETURN_COOKIE_NAME,
575
844
  DEFAULT_STATE_COOKIE_NAME,
@@ -578,6 +847,7 @@ function createLogoutRouteHandler(config) {
578
847
  createLogoutRouteHandler,
579
848
  createSsoMiddleware,
580
849
  getHostCookieOptions,
581
- getSecureCookieOptions
850
+ getSecureCookieOptions,
851
+ toInsecureCookieName
582
852
  });
583
853
  //# sourceMappingURL=index.js.map