@nihplod/sso-sdk 1.2.1 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,7 @@ var DEFAULT_ACCESS_TOKEN_COOKIE_NAME = "__Host-nihplod_sso_at";
6
6
  var DEFAULT_REFRESH_TOKEN_COOKIE_NAME = "__Host-nihplod_sso_rt";
7
7
  var DEFAULT_ID_TOKEN_COOKIE_NAME = "__Host-nihplod_sso_id";
8
8
  var DEFAULT_STATE_COOKIE_NAME = "__Host-nihplod_sso_state";
9
+ var DEFAULT_NONCE_COOKIE_NAME = "__Host-nihplod_sso_nonce";
9
10
  var DEFAULT_RETURN_COOKIE_NAME = "__Host-nihplod_sso_return";
10
11
  var DEFAULT_VERIFIER_COOKIE_NAME = "__Secure-nihplod_sso_verifier";
11
12
  var DEFAULT_LOGOUT_STATE_COOKIE_NAME = "__Host-nihplod_sso_logout_state";
@@ -161,20 +162,20 @@ function createSsoMiddleware(config) {
161
162
  const secureCookies = !insecureLocalDev;
162
163
  const accessTokenCookieName = insecureLocalDev ? toInsecureCookieName(config.accessTokenCookieName ?? DEFAULT_ACCESS_TOKEN_COOKIE_NAME) : config.accessTokenCookieName ?? DEFAULT_ACCESS_TOKEN_COOKIE_NAME;
163
164
  const stateCookieName = insecureLocalDev ? toInsecureCookieName(config.stateCookieName ?? DEFAULT_STATE_COOKIE_NAME) : config.stateCookieName ?? DEFAULT_STATE_COOKIE_NAME;
165
+ const nonceCookieName = insecureLocalDev ? toInsecureCookieName(config.nonceCookieName ?? DEFAULT_NONCE_COOKIE_NAME) : config.nonceCookieName ?? DEFAULT_NONCE_COOKIE_NAME;
164
166
  const returnUrlCookieName = insecureLocalDev ? toInsecureCookieName(config.returnUrlCookieName ?? DEFAULT_RETURN_COOKIE_NAME) : config.returnUrlCookieName ?? DEFAULT_RETURN_COOKIE_NAME;
165
167
  const verifierCookieName = insecureLocalDev ? toInsecureCookieName(config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME) : config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME;
166
168
  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
- }
169
+ const normalizedServerBase = (config.serverBaseUrl ?? ssoBaseUrl).replace(/\/+$/, "");
170
+ if (!validateSsoCookie) {
171
+ console.warn(
172
+ "[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"
173
+ );
174
+ }
175
+ if (!clientSecret) {
176
+ console.warn(
177
+ "[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"
178
+ );
178
179
  }
179
180
  return async function ssoMiddleware(request) {
180
181
  const { pathname } = request.nextUrl;
@@ -193,7 +194,7 @@ function createSsoMiddleware(config) {
193
194
  if (validateSsoCookie) {
194
195
  const tokenActive = await introspectAccessToken(
195
196
  ssoSession.value,
196
- normalizedBase,
197
+ normalizedServerBase,
197
198
  clientId,
198
199
  clientSecret
199
200
  );
@@ -208,7 +209,7 @@ function createSsoMiddleware(config) {
208
209
  if (accessTokenCookie?.value) {
209
210
  const tokenActive = await introspectAccessToken(
210
211
  accessTokenCookie.value,
211
- normalizedBase,
212
+ normalizedServerBase,
212
213
  clientId,
213
214
  clientSecret
214
215
  );
@@ -217,6 +218,7 @@ function createSsoMiddleware(config) {
217
218
  }
218
219
  }
219
220
  const state = generateRandomString(32);
221
+ const nonce = generateRandomString(43);
220
222
  const verifier = generateRandomString(64);
221
223
  const challenge = await computeCodeChallenge(verifier);
222
224
  const authorizeParams = new URLSearchParams();
@@ -225,12 +227,14 @@ function createSsoMiddleware(config) {
225
227
  authorizeParams.set("redirect_uri", redirectUri);
226
228
  authorizeParams.set("scope", scopes);
227
229
  authorizeParams.set("state", state);
230
+ authorizeParams.set("nonce", nonce);
228
231
  authorizeParams.set("code_challenge", challenge);
229
232
  authorizeParams.set("code_challenge_method", "S256");
230
233
  const loginUrl = new URL("/api/oauth/authorize", normalizedBase);
231
234
  loginUrl.search = authorizeParams.toString();
232
235
  const response = NextResponse.redirect(loginUrl);
233
236
  response.cookies.set(stateCookieName, state, getHostCookieOptions(600, secureCookies));
237
+ response.cookies.set(nonceCookieName, nonce, getHostCookieOptions(600, secureCookies));
234
238
  response.cookies.set(verifierCookieName, verifier, getSecureCookieOptions(600, callbackPath, secureCookies));
235
239
  const safeReturnUrl = (request.nextUrl.pathname + request.nextUrl.search).slice(0, 2048);
236
240
  response.cookies.set(returnUrlCookieName, safeReturnUrl, getHostCookieOptions(600, secureCookies));
@@ -319,18 +323,21 @@ function normalizeIssuer(url) {
319
323
  var cachedJwks = null;
320
324
  var cachedDiscovery = null;
321
325
  var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
326
+ var FETCH_TIMEOUT_MS = 1e4;
322
327
  async function fetchDiscoveryDoc(baseUrl) {
323
328
  const now = Date.now();
324
329
  if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
325
330
  return cachedDiscovery.doc;
326
331
  }
327
332
  try {
328
- const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`);
329
- const doc = res.ok ? await res.json() : null;
333
+ const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`, {
334
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
335
+ });
336
+ if (!res.ok) return null;
337
+ const doc = await res.json();
330
338
  cachedDiscovery = { baseUrl, doc, fetchedAt: now };
331
339
  return doc;
332
340
  } catch {
333
- cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
334
341
  return null;
335
342
  }
336
343
  }
@@ -342,7 +349,10 @@ async function fetchJwks(baseUrl, options = {}) {
342
349
  const discovery = await fetchDiscoveryDoc(baseUrl);
343
350
  const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
344
351
  try {
345
- const res = await fetch(jwksUri, options.forceRefresh ? { cache: "no-cache" } : void 0);
352
+ const res = await fetch(jwksUri, {
353
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
354
+ ...options.forceRefresh ? { cache: "no-cache" } : {}
355
+ });
346
356
  if (!res.ok) return null;
347
357
  const jwks = await res.json();
348
358
  cachedJwks = { baseUrl, jwks, fetchedAt: now };
@@ -389,7 +399,7 @@ async function computeAtHash(accessToken) {
389
399
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
390
400
  }
391
401
  async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
392
- const { rejectHs256WhenRs256Available = true } = options;
402
+ const { rejectHs256WhenRs256Available = true, expectedNonce } = options;
393
403
  const header = decodeJwtHeader(idToken);
394
404
  if (!header) {
395
405
  throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
@@ -479,6 +489,12 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
479
489
  throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
480
490
  }
481
491
  }
492
+ if (expectedNonce !== void 0) {
493
+ const tokenNonce = typeof payload.nonce === "string" ? payload.nonce : "";
494
+ if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
495
+ throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
496
+ }
497
+ }
482
498
  return { sub: payload.sub };
483
499
  }
484
500
 
@@ -499,10 +515,19 @@ function createCallbackRouteHandler(config) {
499
515
  const refreshTokenCookieName = pickName(config.refreshTokenCookieName, DEFAULT_REFRESH_TOKEN_COOKIE_NAME);
500
516
  const idTokenCookieName = pickName(config.idTokenCookieName, DEFAULT_ID_TOKEN_COOKIE_NAME);
501
517
  const stateCookieName = pickName(config.stateCookieName, DEFAULT_STATE_COOKIE_NAME);
518
+ const nonceCookieName = pickName(config.nonceCookieName, DEFAULT_NONCE_COOKIE_NAME);
502
519
  const returnUrlCookieName = pickName(config.returnUrlCookieName, DEFAULT_RETURN_COOKIE_NAME);
503
520
  const verifierCookieName = pickName(config.verifierCookieName, DEFAULT_VERIFIER_COOKIE_NAME);
504
521
  const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
522
+ const normalizedServerBase = (config.serverBaseUrl ?? ssoBaseUrl).replace(/\/+$/, "");
505
523
  return async function GET(request) {
524
+ const response = await handleCallback(request);
525
+ if (response.status >= 400) {
526
+ response.cookies.set(nonceCookieName, "", getHostCookieOptions(0, secureCookies));
527
+ }
528
+ return response;
529
+ };
530
+ async function handleCallback(request) {
506
531
  const { searchParams } = request.nextUrl;
507
532
  const error = searchParams.get("error");
508
533
  if (error) {
@@ -533,7 +558,7 @@ function createCallbackRouteHandler(config) {
533
558
  { status: 400 }
534
559
  );
535
560
  }
536
- if (returnedState !== savedState) {
561
+ if (!timingSafeEqualString(returnedState ?? "", savedState)) {
537
562
  return NextResponse2.json(
538
563
  {
539
564
  error: "invalid_request",
@@ -542,6 +567,7 @@ function createCallbackRouteHandler(config) {
542
567
  { status: 400 }
543
568
  );
544
569
  }
570
+ const expectedNonce = request.cookies.get(nonceCookieName)?.value;
545
571
  const verifier = request.cookies.get(verifierCookieName)?.value;
546
572
  if (!verifier) {
547
573
  return NextResponse2.json(
@@ -552,7 +578,7 @@ function createCallbackRouteHandler(config) {
552
578
  { status: 400 }
553
579
  );
554
580
  }
555
- const tokenEndpoint = `${normalizedBase}/api/oauth/token`;
581
+ const tokenEndpoint = `${normalizedServerBase}/api/oauth/token`;
556
582
  const body = new URLSearchParams();
557
583
  body.set("grant_type", "authorization_code");
558
584
  body.set("code", code);
@@ -623,7 +649,8 @@ function createCallbackRouteHandler(config) {
623
649
  tokenData.id_token,
624
650
  tokenData.access_token,
625
651
  normalizedBase,
626
- clientId
652
+ clientId,
653
+ { expectedNonce }
627
654
  );
628
655
  } catch (err) {
629
656
  return NextResponse2.json(
@@ -667,30 +694,68 @@ function createCallbackRouteHandler(config) {
667
694
  }
668
695
  }
669
696
  response.cookies.set(stateCookieName, "", getHostCookieOptions(0, secureCookies));
697
+ response.cookies.set(nonceCookieName, "", getHostCookieOptions(0, secureCookies));
670
698
  response.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0, secureCookies));
671
699
  response.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/", secureCookies));
672
700
  response.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, request.nextUrl.pathname, secureCookies));
673
701
  return response;
674
- };
702
+ }
675
703
  }
676
704
 
677
705
  // src/next/logout.ts
678
706
  import { NextResponse as NextResponse3 } from "next/server";
679
- async function fetchDiscovery(ssoBaseUrl) {
680
- const controller = new AbortController();
681
- const timeoutId = setTimeout(() => controller.abort(), 5e3);
682
- try {
683
- const res = await fetch(
684
- `${ssoBaseUrl}/api/oauth/.well-known/openid-configuration`,
685
- { signal: controller.signal }
686
- );
687
- if (!res.ok) return null;
688
- return await res.json();
689
- } catch {
690
- return null;
691
- } finally {
692
- clearTimeout(timeoutId);
693
- }
707
+
708
+ // src/core/discovery.ts
709
+ var DISCOVERY_CACHE_TTL_MS = 5 * 60 * 1e3;
710
+ var FETCH_TIMEOUT_MS2 = 5e3;
711
+ var cache = /* @__PURE__ */ new Map();
712
+ var inflight = /* @__PURE__ */ new Map();
713
+ function fetchDiscoveryCached(baseUrl) {
714
+ const hit = cache.get(baseUrl);
715
+ if (hit && hit.expiresAt > Date.now()) {
716
+ return Promise.resolve(hit.doc);
717
+ }
718
+ const existing = inflight.get(baseUrl);
719
+ if (existing) return existing;
720
+ const promise = (async () => {
721
+ const controller = new AbortController();
722
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS2);
723
+ try {
724
+ const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`, {
725
+ signal: controller.signal
726
+ });
727
+ if (!res.ok) return null;
728
+ const doc = await res.json();
729
+ cache.set(baseUrl, { doc, expiresAt: Date.now() + DISCOVERY_CACHE_TTL_MS });
730
+ return doc;
731
+ } catch {
732
+ return null;
733
+ } finally {
734
+ clearTimeout(timeoutId);
735
+ inflight.delete(baseUrl);
736
+ }
737
+ })();
738
+ inflight.set(baseUrl, promise);
739
+ return promise;
740
+ }
741
+
742
+ // src/next/logout.ts
743
+ function buildLogoutConfirmHtml() {
744
+ return `<!doctype html>
745
+ <html lang="zh-CN">
746
+ <head>
747
+ <meta charset="utf-8">
748
+ <meta name="viewport" content="width=device-width, initial-scale=1">
749
+ <title>\u786E\u8BA4\u9000\u51FA\u767B\u5F55</title>
750
+ </head>
751
+ <body style="display:flex;align-items:center;justify-content:center;min-height:100vh;font-family:system-ui,sans-serif;">
752
+ <form method="post" style="text-align:center;">
753
+ <p>\u786E\u5B9A\u8981\u9000\u51FA\u767B\u5F55\u5417\uFF1F</p>
754
+ <button type="submit" style="padding:10px 20px;background-color:#ef4444;color:white;border:none;border-radius:8px;cursor:pointer;font-size:14px;">\u9000\u51FA\u767B\u5F55</button>
755
+ <p><a href="/" style="color:#2563eb;text-decoration:underline;">\u53D6\u6D88\u5E76\u8FD4\u56DE\u9996\u9875</a></p>
756
+ </form>
757
+ </body>
758
+ </html>`;
694
759
  }
695
760
  function generateRandomString2(length) {
696
761
  const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
@@ -724,10 +789,12 @@ function createLogoutRouteHandler(config) {
724
789
  const refreshTokenCookieName = pickName(config.refreshTokenCookieName, DEFAULT_REFRESH_TOKEN_COOKIE_NAME);
725
790
  const idTokenCookieName = pickName(config.idTokenCookieName, DEFAULT_ID_TOKEN_COOKIE_NAME);
726
791
  const stateCookieName = pickName(config.stateCookieName, DEFAULT_STATE_COOKIE_NAME);
792
+ const nonceCookieName = pickName(config.nonceCookieName, DEFAULT_NONCE_COOKIE_NAME);
727
793
  const returnUrlCookieName = pickName(config.returnUrlCookieName, DEFAULT_RETURN_COOKIE_NAME);
728
794
  const verifierCookieName = pickName(config.verifierCookieName, DEFAULT_VERIFIER_COOKIE_NAME);
729
795
  const logoutStateCookieName = pickName(config.logoutStateCookieName, DEFAULT_LOGOUT_STATE_COOKIE_NAME);
730
796
  const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
797
+ const normalizedServerBase = (config.serverBaseUrl ?? ssoBaseUrl).replace(/\/+$/, "");
731
798
  const callbackOrigin = new URL(redirectUri).origin;
732
799
  return async function handler(request) {
733
800
  const returnedState = request.nextUrl.searchParams.get("state");
@@ -743,12 +810,18 @@ function createLogoutRouteHandler(config) {
743
810
  res.cookies.set(logoutStateCookieName, "", getHostCookieOptions(0, secureCookies));
744
811
  return res;
745
812
  }
813
+ if (request.method === "GET") {
814
+ return new NextResponse3(buildLogoutConfirmHtml(), {
815
+ status: 200,
816
+ headers: { "content-type": "text/html; charset=utf-8" }
817
+ });
818
+ }
746
819
  const refreshToken = request.cookies.get(refreshTokenCookieName)?.value;
747
820
  const idTokenHint = request.cookies.get(idTokenCookieName)?.value;
748
821
  if (refreshToken) {
749
822
  try {
750
- const discovery = await fetchDiscovery(normalizedBase);
751
- const revokeUrl = discovery?.revocation_endpoint || `${normalizedBase}/api/oauth/revoke`;
823
+ const discovery = config.serverBaseUrl ? null : await fetchDiscoveryCached(normalizedServerBase);
824
+ const revokeUrl = config.serverBaseUrl ? `${normalizedServerBase}/api/oauth/revoke` : discovery?.revocation_endpoint || `${normalizedBase}/api/oauth/revoke`;
752
825
  const body = new URLSearchParams({
753
826
  token: refreshToken,
754
827
  token_type_hint: "refresh_token",
@@ -760,7 +833,8 @@ function createLogoutRouteHandler(config) {
760
833
  await fetch(revokeUrl, {
761
834
  method: "POST",
762
835
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
763
- body: body.toString()
836
+ body: body.toString(),
837
+ signal: AbortSignal.timeout(3e3)
764
838
  });
765
839
  } catch {
766
840
  }
@@ -770,13 +844,14 @@ function createLogoutRouteHandler(config) {
770
844
  res.cookies.set(refreshTokenCookieName, "", getHostCookieOptions(0, secureCookies));
771
845
  res.cookies.set(idTokenCookieName, "", getHostCookieOptions(0, secureCookies));
772
846
  res.cookies.set(stateCookieName, "", getHostCookieOptions(0, secureCookies));
847
+ res.cookies.set(nonceCookieName, "", getHostCookieOptions(0, secureCookies));
773
848
  res.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0, secureCookies));
774
849
  res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/", secureCookies));
775
850
  res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, callbackPath, secureCookies));
776
851
  return res;
777
852
  };
778
853
  if (redirectToSso) {
779
- const discovery = await fetchDiscovery(normalizedBase);
854
+ const discovery = await fetchDiscoveryCached(normalizedServerBase);
780
855
  const endSessionEndpoint = discovery?.end_session_endpoint || `${normalizedBase}/api/oauth/end-session`;
781
856
  const logoutUrl = new URL(endSessionEndpoint);
782
857
  logoutUrl.searchParams.set("client_id", clientId);
@@ -802,6 +877,7 @@ export {
802
877
  DEFAULT_ACCESS_TOKEN_COOKIE_NAME,
803
878
  DEFAULT_ID_TOKEN_COOKIE_NAME,
804
879
  DEFAULT_LOGOUT_STATE_COOKIE_NAME,
880
+ DEFAULT_NONCE_COOKIE_NAME,
805
881
  DEFAULT_REFRESH_TOKEN_COOKIE_NAME,
806
882
  DEFAULT_RETURN_COOKIE_NAME,
807
883
  DEFAULT_STATE_COOKIE_NAME,