@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.
@@ -8,19 +8,36 @@ 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
- function getHostCookieOptions(maxAge) {
11
+ var DEFAULT_LOGOUT_STATE_COOKIE_NAME = "__Host-nihplod_sso_logout_state";
12
+ function toInsecureCookieName(name) {
13
+ return name.replace(/^__(Host|Secure)-/, "");
14
+ }
15
+ function resolveInsecureLocalDev(enabled, ssoBaseUrl) {
16
+ if (!enabled) return false;
17
+ if (process.env.NODE_ENV === "production" && /^https:\/\//i.test(ssoBaseUrl)) {
18
+ console.warn(
19
+ "[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"
20
+ );
21
+ return false;
22
+ }
23
+ console.warn(
24
+ "[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"
25
+ );
26
+ return true;
27
+ }
28
+ function getHostCookieOptions(maxAge, secure = true) {
12
29
  return {
13
30
  httpOnly: true,
14
- secure: true,
31
+ secure,
15
32
  sameSite: "lax",
16
33
  path: "/",
17
34
  ...maxAge !== void 0 ? { maxAge } : {}
18
35
  };
19
36
  }
20
- function getSecureCookieOptions(maxAge, path = "/") {
37
+ function getSecureCookieOptions(maxAge, path = "/", secure = true) {
21
38
  return {
22
39
  httpOnly: true,
23
- secure: true,
40
+ secure,
24
41
  sameSite: "lax",
25
42
  path,
26
43
  ...maxAge !== void 0 ? { maxAge } : {}
@@ -61,7 +78,43 @@ function matchesPath(pathname, paths) {
61
78
  return false;
62
79
  });
63
80
  }
81
+ var introspectionCache = /* @__PURE__ */ new Map();
82
+ var INTROSPECT_CACHE_TTL_MS = 3e4;
83
+ var INTROSPECT_CACHE_MAX_ENTRIES = 500;
84
+ async function introspectCacheKey(token, clientId) {
85
+ const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
86
+ const hex = Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
87
+ return `${hex}|${clientId}`;
88
+ }
89
+ function introspectCacheGet(key) {
90
+ const entry = introspectionCache.get(key);
91
+ if (!entry) return null;
92
+ if (entry.until <= Date.now()) {
93
+ introspectionCache.delete(key);
94
+ return null;
95
+ }
96
+ introspectionCache.delete(key);
97
+ introspectionCache.set(key, entry);
98
+ return entry;
99
+ }
100
+ function introspectCacheSet(key, active) {
101
+ const now = Date.now();
102
+ for (const [k, v] of introspectionCache) {
103
+ if (v.until <= now) introspectionCache.delete(k);
104
+ }
105
+ while (introspectionCache.size >= INTROSPECT_CACHE_MAX_ENTRIES) {
106
+ const oldest = introspectionCache.keys().next();
107
+ if (oldest.done) break;
108
+ introspectionCache.delete(oldest.value);
109
+ }
110
+ introspectionCache.set(key, { active, until: now + INTROSPECT_CACHE_TTL_MS });
111
+ }
64
112
  async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret) {
113
+ const cacheKey = await introspectCacheKey(token, clientId);
114
+ const cached = introspectCacheGet(cacheKey);
115
+ if (cached) {
116
+ return cached.active;
117
+ }
65
118
  try {
66
119
  const body = new URLSearchParams({
67
120
  token,
@@ -76,9 +129,17 @@ async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret)
76
129
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
77
130
  body: body.toString()
78
131
  });
79
- if (!res.ok) return false;
132
+ if (!res.ok) {
133
+ if (res.status !== 401 && res.status !== 403) {
134
+ return false;
135
+ }
136
+ introspectCacheSet(cacheKey, false);
137
+ return false;
138
+ }
80
139
  const data = await res.json();
81
- return data.active === true;
140
+ const active = data.active === true;
141
+ introspectCacheSet(cacheKey, active);
142
+ return active;
82
143
  } catch {
83
144
  return false;
84
145
  }
@@ -93,12 +154,28 @@ function createSsoMiddleware(config) {
93
154
  publicPaths = [],
94
155
  callbackPath = "/api/auth/callback",
95
156
  ssoCookieName = "__Host-user_token",
96
- accessTokenCookieName = DEFAULT_ACCESS_TOKEN_COOKIE_NAME,
97
- stateCookieName = DEFAULT_STATE_COOKIE_NAME,
98
- returnUrlCookieName = DEFAULT_RETURN_COOKIE_NAME,
99
- verifierCookieName = DEFAULT_VERIFIER_COOKIE_NAME
157
+ validateSsoCookie = true,
158
+ insecureLocalDev: insecureLocalDevOpt = false
100
159
  } = config;
160
+ const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
161
+ const secureCookies = !insecureLocalDev;
162
+ const accessTokenCookieName = insecureLocalDev ? toInsecureCookieName(config.accessTokenCookieName ?? DEFAULT_ACCESS_TOKEN_COOKIE_NAME) : config.accessTokenCookieName ?? DEFAULT_ACCESS_TOKEN_COOKIE_NAME;
163
+ const stateCookieName = insecureLocalDev ? toInsecureCookieName(config.stateCookieName ?? DEFAULT_STATE_COOKIE_NAME) : config.stateCookieName ?? DEFAULT_STATE_COOKIE_NAME;
164
+ const returnUrlCookieName = insecureLocalDev ? toInsecureCookieName(config.returnUrlCookieName ?? DEFAULT_RETURN_COOKIE_NAME) : config.returnUrlCookieName ?? DEFAULT_RETURN_COOKIE_NAME;
165
+ const verifierCookieName = insecureLocalDev ? toInsecureCookieName(config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME) : config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME;
101
166
  const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
167
+ if (process.env.NODE_ENV !== "production") {
168
+ if (!validateSsoCookie) {
169
+ console.warn(
170
+ "[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"
171
+ );
172
+ }
173
+ if (!clientSecret) {
174
+ console.warn(
175
+ "[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"
176
+ );
177
+ }
178
+ }
102
179
  return async function ssoMiddleware(request) {
103
180
  const { pathname } = request.nextUrl;
104
181
  if (pathname.startsWith("/_next/") || pathname.startsWith("/favicon.ico") || pathname.match(/\.(ico|png|jpg|jpeg|svg|css|js|woff2?)$/)) {
@@ -113,7 +190,19 @@ function createSsoMiddleware(config) {
113
190
  }
114
191
  const ssoSession = request.cookies.get(ssoCookieName);
115
192
  if (ssoSession?.value) {
116
- return NextResponse.next();
193
+ if (validateSsoCookie) {
194
+ const tokenActive = await introspectAccessToken(
195
+ ssoSession.value,
196
+ normalizedBase,
197
+ clientId,
198
+ clientSecret
199
+ );
200
+ if (tokenActive) {
201
+ return NextResponse.next();
202
+ }
203
+ } else {
204
+ return NextResponse.next();
205
+ }
117
206
  }
118
207
  const accessTokenCookie = request.cookies.get(accessTokenCookieName);
119
208
  if (accessTokenCookie?.value) {
@@ -141,11 +230,12 @@ function createSsoMiddleware(config) {
141
230
  const loginUrl = new URL("/api/oauth/authorize", normalizedBase);
142
231
  loginUrl.search = authorizeParams.toString();
143
232
  const response = NextResponse.redirect(loginUrl);
144
- response.cookies.set(stateCookieName, state, getHostCookieOptions(600));
145
- response.cookies.set(verifierCookieName, verifier, getSecureCookieOptions(600, callbackPath));
146
- response.cookies.set(returnUrlCookieName, request.url, getHostCookieOptions(600));
233
+ response.cookies.set(stateCookieName, state, getHostCookieOptions(600, secureCookies));
234
+ response.cookies.set(verifierCookieName, verifier, getSecureCookieOptions(600, callbackPath, secureCookies));
235
+ const safeReturnUrl = (request.nextUrl.pathname + request.nextUrl.search).slice(0, 2048);
236
+ response.cookies.set(returnUrlCookieName, safeReturnUrl, getHostCookieOptions(600, secureCookies));
147
237
  if (accessTokenCookie?.value) {
148
- response.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0));
238
+ response.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0, secureCookies));
149
239
  }
150
240
  return response;
151
241
  };
@@ -153,8 +243,22 @@ function createSsoMiddleware(config) {
153
243
 
154
244
  // src/next/callback.ts
155
245
  import { NextResponse as NextResponse2 } from "next/server";
246
+
247
+ // src/core/errors.ts
248
+ var SsoError = class extends Error {
249
+ constructor(code, description, cause) {
250
+ super(`[SSO SDK] ${code}: ${description}`);
251
+ this.name = "SsoError";
252
+ this.code = code;
253
+ this.description = description;
254
+ this.cause = cause;
255
+ }
256
+ };
257
+
258
+ // src/core/security.ts
156
259
  function isTrustedReturnUrl(url, currentOrigin) {
157
260
  if (!url) return false;
261
+ if (url.includes("\\")) return false;
158
262
  if (url.startsWith("/") && !url.startsWith("//")) return true;
159
263
  try {
160
264
  return new URL(url).origin === currentOrigin;
@@ -162,6 +266,23 @@ function isTrustedReturnUrl(url, currentOrigin) {
162
266
  return false;
163
267
  }
164
268
  }
269
+ function timingSafeEqualString(a, b) {
270
+ const ba = new TextEncoder().encode(a);
271
+ const bb = new TextEncoder().encode(b);
272
+ if (ba.length === 0 || bb.length === 0) return ba.length === bb.length;
273
+ const len = Math.max(ba.length, bb.length);
274
+ let diff = ba.length ^ bb.length;
275
+ for (let i = 0; i < len; i++) {
276
+ diff |= ba[i % ba.length] ^ bb[i % bb.length];
277
+ }
278
+ return diff === 0;
279
+ }
280
+
281
+ // src/core/id-token.ts
282
+ var _crypto = typeof crypto !== "undefined" && crypto.subtle ? crypto : null;
283
+ function getCrypto() {
284
+ return _crypto ?? null;
285
+ }
165
286
  function base64UrlDecode(input) {
166
287
  const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
167
288
  const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
@@ -192,11 +313,40 @@ function decodeJwtHeader(token) {
192
313
  return null;
193
314
  }
194
315
  }
195
- async function fetchJwks(baseUrl) {
316
+ function normalizeIssuer(url) {
317
+ return url.replace(/\/+$/, "");
318
+ }
319
+ var cachedJwks = null;
320
+ var cachedDiscovery = null;
321
+ var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
322
+ async function fetchDiscoveryDoc(baseUrl) {
323
+ const now = Date.now();
324
+ if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
325
+ return cachedDiscovery.doc;
326
+ }
327
+ try {
328
+ const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`);
329
+ const doc = res.ok ? await res.json() : null;
330
+ cachedDiscovery = { baseUrl, doc, fetchedAt: now };
331
+ return doc;
332
+ } catch {
333
+ cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
334
+ return null;
335
+ }
336
+ }
337
+ async function fetchJwks(baseUrl, options = {}) {
338
+ const now = Date.now();
339
+ if (!options.forceRefresh && cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
340
+ return cachedJwks.jwks;
341
+ }
342
+ const discovery = await fetchDiscoveryDoc(baseUrl);
343
+ const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
196
344
  try {
197
- const res = await fetch(`${baseUrl}/api/oauth/jwks`);
345
+ const res = await fetch(jwksUri, options.forceRefresh ? { cache: "no-cache" } : void 0);
198
346
  if (!res.ok) return null;
199
- return await res.json();
347
+ const jwks = await res.json();
348
+ cachedJwks = { baseUrl, jwks, fetchedAt: now };
349
+ return jwks;
200
350
  } catch {
201
351
  return null;
202
352
  }
@@ -205,7 +355,9 @@ async function verifyRs256Signature(token, jwk) {
205
355
  try {
206
356
  const [headerB64, payloadB64, signature] = token.split(".");
207
357
  if (!signature || !jwk.n || !jwk.e) return false;
208
- const cryptoKey = await crypto.subtle.importKey(
358
+ const c = getCrypto();
359
+ if (!c) return false;
360
+ const cryptoKey = await c.subtle.importKey(
209
361
  "jwk",
210
362
  { kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: false },
211
363
  { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
@@ -214,7 +366,7 @@ async function verifyRs256Signature(token, jwk) {
214
366
  );
215
367
  const signatureBytes = base64UrlDecode(signature);
216
368
  const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
217
- return await crypto.subtle.verify(
369
+ return await c.subtle.verify(
218
370
  "RSASSA-PKCS1-v1_5",
219
371
  cryptoKey,
220
372
  signatureBytes,
@@ -225,10 +377,9 @@ async function verifyRs256Signature(token, jwk) {
225
377
  }
226
378
  }
227
379
  async function computeAtHash(accessToken) {
228
- const hash = await crypto.subtle.digest(
229
- "SHA-256",
230
- new TextEncoder().encode(accessToken)
231
- );
380
+ const c = getCrypto();
381
+ if (!c) return "";
382
+ const hash = await c.subtle.digest("SHA-256", new TextEncoder().encode(accessToken));
232
383
  const bytes = new Uint8Array(hash);
233
384
  const half = bytes.slice(0, bytes.length / 2);
234
385
  let binary = "";
@@ -237,54 +388,101 @@ async function computeAtHash(accessToken) {
237
388
  }
238
389
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
239
390
  }
240
- async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId) {
391
+ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
392
+ const { rejectHs256WhenRs256Available = true } = options;
241
393
  const header = decodeJwtHeader(idToken);
242
- if (!header) throw new Error("ID Token \u683C\u5F0F\u9519\u8BEF");
394
+ if (!header) {
395
+ throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
396
+ }
243
397
  const alg = header.alg;
244
398
  if (typeof alg !== "string" || alg !== "RS256" && alg !== "HS256") {
245
- throw new Error(`\u4E0D\u652F\u6301\u7684 ID Token \u7B7E\u540D\u7B97\u6CD5: ${alg}`);
399
+ throw new SsoError("id_token_unsupported_alg", `\u4E0D\u652F\u6301\u7684 ID Token \u7B7E\u540D\u7B97\u6CD5: ${alg}`);
246
400
  }
401
+ const baseUrl = normalizeIssuer(expectedIssuer);
402
+ const discovery = await fetchDiscoveryDoc(baseUrl);
403
+ const normalizedIssuer = normalizeIssuer(discovery?.issuer || baseUrl);
247
404
  if (alg === "RS256") {
248
- const jwks = await fetchJwks(expectedIssuer);
249
- if (!jwks) throw new Error("\u65E0\u6CD5\u83B7\u53D6 JWKS");
405
+ const jwks = await fetchJwks(baseUrl);
406
+ if (!jwks) {
407
+ throw new SsoError("id_token_invalid_signature", "\u65E0\u6CD5\u83B7\u53D6 JWKS \u9A8C\u8BC1 ID Token \u7B7E\u540D");
408
+ }
250
409
  const kid = typeof header.kid === "string" ? header.kid : void 0;
251
- const key = jwks.keys.find(
410
+ const matchCandidates = (set) => set.keys.filter(
252
411
  (k) => k.kty === "RSA" && k.alg === "RS256" && k.use === "sig" && (kid ? k.kid === kid : true)
253
412
  );
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");
413
+ const verifyAny = async (keys) => {
414
+ for (const key of keys) {
415
+ if (await verifyRs256Signature(idToken, key)) return true;
416
+ }
417
+ return false;
418
+ };
419
+ let candidates = matchCandidates(jwks);
420
+ let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
421
+ if (candidates.length === 0 || !validSig) {
422
+ const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });
423
+ if (freshJwks) {
424
+ candidates = matchCandidates(freshJwks);
425
+ validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
426
+ }
427
+ }
428
+ if (candidates.length === 0) {
429
+ throw new SsoError("id_token_invalid_signature", "JWKS \u4E2D\u672A\u627E\u5230\u5339\u914D\u7684 RS256 \u516C\u94A5");
430
+ }
431
+ if (!validSig) {
432
+ throw new SsoError("id_token_invalid_signature", "ID Token \u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25");
433
+ }
257
434
  }
258
435
  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"
436
+ if (rejectHs256WhenRs256Available) {
437
+ const jwks = await fetchJwks(baseUrl);
438
+ const hasRs256 = jwks?.keys?.some((k) => k.alg === "RS256" && k.use === "sig");
439
+ if (hasRs256) {
440
+ throw new SsoError("id_token_unsupported_alg", "SSO \u4E2D\u5FC3\u5DF2\u914D\u7F6E RS256\uFF0C\u62D2\u7EDD HS256 ID Token");
441
+ }
442
+ }
443
+ throw new SsoError(
444
+ "id_token_hs256_unsupported",
445
+ "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
446
  );
262
447
  }
263
448
  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(/\/+$/, "") : "";
449
+ if (!payload) {
450
+ throw new SsoError("id_token_invalid", "ID Token payload \u89E3\u6790\u5931\u8D25");
451
+ }
452
+ const tokenIssuer = typeof payload.iss === "string" ? normalizeIssuer(payload.iss) : "";
267
453
  if (tokenIssuer !== normalizedIssuer) {
268
- throw new Error("ID Token issuer \u4E0D\u5339\u914D");
454
+ throw new SsoError("id_token_issuer_mismatch", "ID Token issuer \u4E0D\u5339\u914D");
269
455
  }
270
456
  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");
457
+ const audList = Array.isArray(aud) ? aud : typeof aud === "string" ? [aud] : [];
458
+ if (!audList.includes(expectedClientId)) {
459
+ throw new SsoError("id_token_audience_mismatch", "ID Token audience \u4E0D\u5339\u914D");
460
+ }
461
+ if (audList.length > 1 && payload.azp !== expectedClientId) {
462
+ throw new SsoError("id_token_audience_mismatch", "ID Token \u591A audience \u65F6 azp \u5FC5\u987B\u4E3A\u5F53\u524D client");
274
463
  }
275
- if (typeof payload.exp === "number" && Date.now() >= payload.exp * 1e3) {
276
- throw new Error("ID Token \u5DF2\u8FC7\u671F");
464
+ if (typeof payload.exp !== "number") {
465
+ throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 exp \u58F0\u660E");
466
+ }
467
+ if (Date.now() >= payload.exp * 1e3 + 6e4) {
468
+ throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
469
+ }
470
+ if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
471
+ throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
277
472
  }
278
473
  if (typeof payload.sub !== "string" || !payload.sub) {
279
- throw new Error("ID Token \u7F3A\u5C11 sub");
474
+ throw new SsoError("id_token_missing_sub", "ID Token \u7F3A\u5C11 sub");
280
475
  }
281
476
  if (typeof payload.at_hash === "string" && payload.at_hash) {
282
477
  const actual = await computeAtHash(accessToken);
283
- if (actual !== payload.at_hash) {
284
- throw new Error("ID Token at_hash \u4E0D\u5339\u914D");
478
+ if (!timingSafeEqualString(actual, payload.at_hash)) {
479
+ throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
285
480
  }
286
481
  }
482
+ return { sub: payload.sub };
287
483
  }
484
+
485
+ // src/next/callback.ts
288
486
  function createCallbackRouteHandler(config) {
289
487
  const {
290
488
  clientId,
@@ -292,13 +490,17 @@ function createCallbackRouteHandler(config) {
292
490
  redirectUri,
293
491
  clientSecret,
294
492
  defaultReturnPath = "/",
295
- accessTokenCookieName = DEFAULT_ACCESS_TOKEN_COOKIE_NAME,
296
- refreshTokenCookieName = DEFAULT_REFRESH_TOKEN_COOKIE_NAME,
297
- idTokenCookieName = DEFAULT_ID_TOKEN_COOKIE_NAME,
298
- stateCookieName = DEFAULT_STATE_COOKIE_NAME,
299
- returnUrlCookieName = DEFAULT_RETURN_COOKIE_NAME,
300
- verifierCookieName = DEFAULT_VERIFIER_COOKIE_NAME
493
+ insecureLocalDev: insecureLocalDevOpt = false
301
494
  } = config;
495
+ const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
496
+ const secureCookies = !insecureLocalDev;
497
+ const pickName = (explicit, fallback) => insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : explicit ?? fallback;
498
+ const accessTokenCookieName = pickName(config.accessTokenCookieName, DEFAULT_ACCESS_TOKEN_COOKIE_NAME);
499
+ const refreshTokenCookieName = pickName(config.refreshTokenCookieName, DEFAULT_REFRESH_TOKEN_COOKIE_NAME);
500
+ const idTokenCookieName = pickName(config.idTokenCookieName, DEFAULT_ID_TOKEN_COOKIE_NAME);
501
+ const stateCookieName = pickName(config.stateCookieName, DEFAULT_STATE_COOKIE_NAME);
502
+ const returnUrlCookieName = pickName(config.returnUrlCookieName, DEFAULT_RETURN_COOKIE_NAME);
503
+ const verifierCookieName = pickName(config.verifierCookieName, DEFAULT_VERIFIER_COOKIE_NAME);
302
504
  const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
303
505
  return async function GET(request) {
304
506
  const { searchParams } = request.nextUrl;
@@ -360,7 +562,7 @@ function createCallbackRouteHandler(config) {
360
562
  if (clientSecret) {
361
563
  body.set("client_secret", clientSecret);
362
564
  }
363
- let res;
565
+ let res = null;
364
566
  let lastError;
365
567
  const maxRetries = 1;
366
568
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
@@ -406,6 +608,15 @@ function createCallbackRouteHandler(config) {
406
608
  );
407
609
  }
408
610
  const tokenData = await res.json();
611
+ if (!tokenData.access_token || !tokenData.refresh_token) {
612
+ return NextResponse2.json(
613
+ {
614
+ error: "server_error",
615
+ error_description: "Token \u54CD\u5E94\u7F3A\u5C11 access_token \u6216 refresh_token"
616
+ },
617
+ { status: 502 }
618
+ );
619
+ }
409
620
  if (tokenData.id_token) {
410
621
  try {
411
622
  await validateIdToken(
@@ -428,21 +639,36 @@ function createCallbackRouteHandler(config) {
428
639
  const returnUrl = isTrustedReturnUrl(rawReturnUrl, request.nextUrl.origin) ? rawReturnUrl : "/";
429
640
  const response = NextResponse2.redirect(new URL(returnUrl, request.url));
430
641
  response.cookies.set(accessTokenCookieName, tokenData.access_token, {
431
- ...getHostCookieOptions(tokenData.expires_in)
642
+ ...getHostCookieOptions(tokenData.expires_in, secureCookies)
432
643
  });
433
644
  const refreshMaxAge = tokenData.refresh_expires_in != null ? tokenData.refresh_expires_in : 30 * 24 * 60 * 60;
434
645
  response.cookies.set(refreshTokenCookieName, tokenData.refresh_token, {
435
- ...getHostCookieOptions(refreshMaxAge)
646
+ ...getHostCookieOptions(refreshMaxAge, secureCookies)
436
647
  });
437
648
  if (tokenData.id_token) {
438
- response.cookies.set(idTokenCookieName, tokenData.id_token, {
439
- ...getHostCookieOptions(refreshMaxAge)
440
- });
441
- }
442
- response.cookies.set(stateCookieName, "", getHostCookieOptions(0));
443
- response.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0));
444
- response.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/"));
445
- response.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, request.nextUrl.pathname));
649
+ let idTokenMaxAge = 3600;
650
+ try {
651
+ const parts = tokenData.id_token.split(".");
652
+ if (parts.length === 3) {
653
+ const payload = JSON.parse(
654
+ Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")
655
+ );
656
+ if (payload.exp && typeof payload.exp === "number") {
657
+ idTokenMaxAge = payload.exp - Math.floor(Date.now() / 1e3);
658
+ }
659
+ }
660
+ } catch {
661
+ }
662
+ if (idTokenMaxAge > 0) {
663
+ response.cookies.set(idTokenCookieName, tokenData.id_token, {
664
+ ...getHostCookieOptions(idTokenMaxAge, secureCookies)
665
+ });
666
+ }
667
+ }
668
+ response.cookies.set(stateCookieName, "", getHostCookieOptions(0, secureCookies));
669
+ response.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0, secureCookies));
670
+ response.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/", secureCookies));
671
+ response.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, request.nextUrl.pathname, secureCookies));
446
672
  return response;
447
673
  };
448
674
  }
@@ -450,9 +676,9 @@ function createCallbackRouteHandler(config) {
450
676
  // src/next/logout.ts
451
677
  import { NextResponse as NextResponse3 } from "next/server";
452
678
  async function fetchDiscovery(ssoBaseUrl) {
679
+ const controller = new AbortController();
680
+ const timeoutId = setTimeout(() => controller.abort(), 5e3);
453
681
  try {
454
- const controller = new AbortController();
455
- setTimeout(() => controller.abort(), 5e3);
456
682
  const res = await fetch(
457
683
  `${ssoBaseUrl}/api/oauth/.well-known/openid-configuration`,
458
684
  { signal: controller.signal }
@@ -461,7 +687,23 @@ async function fetchDiscovery(ssoBaseUrl) {
461
687
  return await res.json();
462
688
  } catch {
463
689
  return null;
690
+ } finally {
691
+ clearTimeout(timeoutId);
692
+ }
693
+ }
694
+ function generateRandomString2(length) {
695
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
696
+ const maxValid = Math.floor(256 / chars.length) * chars.length;
697
+ let result = "";
698
+ while (result.length < length) {
699
+ const array = new Uint8Array(length * 2);
700
+ crypto.getRandomValues(array);
701
+ for (let i = 0; i < array.length && result.length < length; i++) {
702
+ if (array[i] >= maxValid) continue;
703
+ result += chars[array[i] % chars.length];
704
+ }
464
705
  }
706
+ return result;
465
707
  }
466
708
  function createLogoutRouteHandler(config) {
467
709
  const {
@@ -471,16 +713,34 @@ function createLogoutRouteHandler(config) {
471
713
  clientSecret,
472
714
  postLogoutRedirectUri = new URL(redirectUri).origin + "/",
473
715
  redirectToSso = true,
474
- accessTokenCookieName = DEFAULT_ACCESS_TOKEN_COOKIE_NAME,
475
- refreshTokenCookieName = DEFAULT_REFRESH_TOKEN_COOKIE_NAME,
476
- idTokenCookieName = DEFAULT_ID_TOKEN_COOKIE_NAME,
477
- stateCookieName = DEFAULT_STATE_COOKIE_NAME,
478
- returnUrlCookieName = DEFAULT_RETURN_COOKIE_NAME,
479
- verifierCookieName = DEFAULT_VERIFIER_COOKIE_NAME,
480
- callbackPath = "/api/auth/callback"
716
+ callbackPath = "/api/auth/callback",
717
+ insecureLocalDev: insecureLocalDevOpt = false
481
718
  } = config;
719
+ const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
720
+ const secureCookies = !insecureLocalDev;
721
+ const pickName = (explicit, fallback) => insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : explicit ?? fallback;
722
+ const accessTokenCookieName = pickName(config.accessTokenCookieName, DEFAULT_ACCESS_TOKEN_COOKIE_NAME);
723
+ const refreshTokenCookieName = pickName(config.refreshTokenCookieName, DEFAULT_REFRESH_TOKEN_COOKIE_NAME);
724
+ const idTokenCookieName = pickName(config.idTokenCookieName, DEFAULT_ID_TOKEN_COOKIE_NAME);
725
+ const stateCookieName = pickName(config.stateCookieName, DEFAULT_STATE_COOKIE_NAME);
726
+ const returnUrlCookieName = pickName(config.returnUrlCookieName, DEFAULT_RETURN_COOKIE_NAME);
727
+ const verifierCookieName = pickName(config.verifierCookieName, DEFAULT_VERIFIER_COOKIE_NAME);
728
+ const logoutStateCookieName = pickName(config.logoutStateCookieName, DEFAULT_LOGOUT_STATE_COOKIE_NAME);
482
729
  const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
483
- 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 NextResponse3.json(
736
+ { error: "invalid_request", error_description: "Logout state \u4E0D\u5339\u914D" },
737
+ { status: 400 }
738
+ );
739
+ }
740
+ const res = NextResponse3.redirect(request.nextUrl.origin + "/");
741
+ res.cookies.set(logoutStateCookieName, "", getHostCookieOptions(0, secureCookies));
742
+ return res;
743
+ }
484
744
  const refreshToken = request.cookies.get(refreshTokenCookieName)?.value;
485
745
  const idTokenHint = request.cookies.get(idTokenCookieName)?.value;
486
746
  if (refreshToken) {
@@ -504,18 +764,18 @@ function createLogoutRouteHandler(config) {
504
764
  }
505
765
  }
506
766
  const clearCookies = (res) => {
507
- res.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0));
508
- res.cookies.set(refreshTokenCookieName, "", getHostCookieOptions(0));
509
- res.cookies.set(idTokenCookieName, "", getHostCookieOptions(0));
510
- res.cookies.set(stateCookieName, "", getHostCookieOptions(0));
511
- res.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0));
512
- res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/"));
513
- res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, callbackPath));
767
+ res.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0, secureCookies));
768
+ res.cookies.set(refreshTokenCookieName, "", getHostCookieOptions(0, secureCookies));
769
+ res.cookies.set(idTokenCookieName, "", getHostCookieOptions(0, secureCookies));
770
+ res.cookies.set(stateCookieName, "", getHostCookieOptions(0, secureCookies));
771
+ res.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0, secureCookies));
772
+ res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/", secureCookies));
773
+ res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, callbackPath, secureCookies));
514
774
  return res;
515
775
  };
516
776
  if (redirectToSso) {
517
777
  const discovery = await fetchDiscovery(normalizedBase);
518
- const endSessionEndpoint = discovery?.end_session_endpoint || `${normalizedBase}/logout`;
778
+ const endSessionEndpoint = discovery?.end_session_endpoint || `${normalizedBase}/api/oauth/end-session`;
519
779
  const logoutUrl = new URL(endSessionEndpoint);
520
780
  logoutUrl.searchParams.set("client_id", clientId);
521
781
  logoutUrl.searchParams.set(
@@ -525,7 +785,11 @@ function createLogoutRouteHandler(config) {
525
785
  if (idTokenHint) {
526
786
  logoutUrl.searchParams.set("id_token_hint", idTokenHint);
527
787
  }
528
- return clearCookies(NextResponse3.redirect(logoutUrl.toString()));
788
+ const logoutState = generateRandomString2(32);
789
+ logoutUrl.searchParams.set("state", logoutState);
790
+ const res = clearCookies(NextResponse3.redirect(logoutUrl.toString()));
791
+ res.cookies.set(logoutStateCookieName, logoutState, getHostCookieOptions(600, secureCookies));
792
+ return res;
529
793
  }
530
794
  return clearCookies(
531
795
  NextResponse3.redirect(request.nextUrl.origin + "/")
@@ -534,6 +798,8 @@ function createLogoutRouteHandler(config) {
534
798
  }
535
799
  export {
536
800
  DEFAULT_ACCESS_TOKEN_COOKIE_NAME,
801
+ DEFAULT_ID_TOKEN_COOKIE_NAME,
802
+ DEFAULT_LOGOUT_STATE_COOKIE_NAME,
537
803
  DEFAULT_REFRESH_TOKEN_COOKIE_NAME,
538
804
  DEFAULT_RETURN_COOKIE_NAME,
539
805
  DEFAULT_STATE_COOKIE_NAME,
@@ -542,6 +808,7 @@ export {
542
808
  createLogoutRouteHandler,
543
809
  createSsoMiddleware,
544
810
  getHostCookieOptions,
545
- getSecureCookieOptions
811
+ getSecureCookieOptions,
812
+ toInsecureCookieName
546
813
  };
547
814
  //# sourceMappingURL=index.mjs.map