@nihplod/sso-sdk 1.3.0 → 1.4.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.
- package/README.md +52 -9
- package/dist/index.d.mts +26 -6
- package/dist/index.d.ts +26 -6
- package/dist/index.js +118 -10
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +118 -10
- package/dist/index.mjs.map +1 -1
- package/dist/next/index.d.mts +81 -4
- package/dist/next/index.d.ts +81 -4
- package/dist/next/index.js +287 -23
- package/dist/next/index.js.map +1 -1
- package/dist/next/index.mjs +285 -23
- package/dist/next/index.mjs.map +1 -1
- package/dist/react/index.d.mts +26 -6
- package/dist/react/index.d.ts +26 -6
- package/dist/react/index.js +176 -16
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +176 -16
- package/dist/react/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/next/index.mjs
CHANGED
|
@@ -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,21 +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
169
|
const normalizedServerBase = (config.serverBaseUrl ?? ssoBaseUrl).replace(/\/+$/, "");
|
|
168
|
-
if (
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
);
|
|
178
|
-
}
|
|
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
|
+
);
|
|
179
179
|
}
|
|
180
180
|
return async function ssoMiddleware(request) {
|
|
181
181
|
const { pathname } = request.nextUrl;
|
|
@@ -218,6 +218,7 @@ function createSsoMiddleware(config) {
|
|
|
218
218
|
}
|
|
219
219
|
}
|
|
220
220
|
const state = generateRandomString(32);
|
|
221
|
+
const nonce = generateRandomString(43);
|
|
221
222
|
const verifier = generateRandomString(64);
|
|
222
223
|
const challenge = await computeCodeChallenge(verifier);
|
|
223
224
|
const authorizeParams = new URLSearchParams();
|
|
@@ -226,12 +227,14 @@ function createSsoMiddleware(config) {
|
|
|
226
227
|
authorizeParams.set("redirect_uri", redirectUri);
|
|
227
228
|
authorizeParams.set("scope", scopes);
|
|
228
229
|
authorizeParams.set("state", state);
|
|
230
|
+
authorizeParams.set("nonce", nonce);
|
|
229
231
|
authorizeParams.set("code_challenge", challenge);
|
|
230
232
|
authorizeParams.set("code_challenge_method", "S256");
|
|
231
233
|
const loginUrl = new URL("/api/oauth/authorize", normalizedBase);
|
|
232
234
|
loginUrl.search = authorizeParams.toString();
|
|
233
235
|
const response = NextResponse.redirect(loginUrl);
|
|
234
236
|
response.cookies.set(stateCookieName, state, getHostCookieOptions(600, secureCookies));
|
|
237
|
+
response.cookies.set(nonceCookieName, nonce, getHostCookieOptions(600, secureCookies));
|
|
235
238
|
response.cookies.set(verifierCookieName, verifier, getSecureCookieOptions(600, callbackPath, secureCookies));
|
|
236
239
|
const safeReturnUrl = (request.nextUrl.pathname + request.nextUrl.search).slice(0, 2048);
|
|
237
240
|
response.cookies.set(returnUrlCookieName, safeReturnUrl, getHostCookieOptions(600, secureCookies));
|
|
@@ -320,18 +323,21 @@ function normalizeIssuer(url) {
|
|
|
320
323
|
var cachedJwks = null;
|
|
321
324
|
var cachedDiscovery = null;
|
|
322
325
|
var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
326
|
+
var FETCH_TIMEOUT_MS = 1e4;
|
|
323
327
|
async function fetchDiscoveryDoc(baseUrl) {
|
|
324
328
|
const now = Date.now();
|
|
325
329
|
if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
|
|
326
330
|
return cachedDiscovery.doc;
|
|
327
331
|
}
|
|
328
332
|
try {
|
|
329
|
-
const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration
|
|
330
|
-
|
|
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();
|
|
331
338
|
cachedDiscovery = { baseUrl, doc, fetchedAt: now };
|
|
332
339
|
return doc;
|
|
333
340
|
} catch {
|
|
334
|
-
cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
|
|
335
341
|
return null;
|
|
336
342
|
}
|
|
337
343
|
}
|
|
@@ -343,7 +349,10 @@ async function fetchJwks(baseUrl, options = {}) {
|
|
|
343
349
|
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
344
350
|
const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
|
|
345
351
|
try {
|
|
346
|
-
const res = await fetch(jwksUri,
|
|
352
|
+
const res = await fetch(jwksUri, {
|
|
353
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
354
|
+
...options.forceRefresh ? { cache: "no-cache" } : {}
|
|
355
|
+
});
|
|
347
356
|
if (!res.ok) return null;
|
|
348
357
|
const jwks = await res.json();
|
|
349
358
|
cachedJwks = { baseUrl, jwks, fetchedAt: now };
|
|
@@ -390,7 +399,7 @@ async function computeAtHash(accessToken) {
|
|
|
390
399
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
391
400
|
}
|
|
392
401
|
async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
|
|
393
|
-
const { rejectHs256WhenRs256Available = true } = options;
|
|
402
|
+
const { rejectHs256WhenRs256Available = true, expectedNonce } = options;
|
|
394
403
|
const header = decodeJwtHeader(idToken);
|
|
395
404
|
if (!header) {
|
|
396
405
|
throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
|
|
@@ -480,6 +489,12 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
|
|
|
480
489
|
throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
|
|
481
490
|
}
|
|
482
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
|
+
}
|
|
483
498
|
return { sub: payload.sub };
|
|
484
499
|
}
|
|
485
500
|
|
|
@@ -500,11 +515,19 @@ function createCallbackRouteHandler(config) {
|
|
|
500
515
|
const refreshTokenCookieName = pickName(config.refreshTokenCookieName, DEFAULT_REFRESH_TOKEN_COOKIE_NAME);
|
|
501
516
|
const idTokenCookieName = pickName(config.idTokenCookieName, DEFAULT_ID_TOKEN_COOKIE_NAME);
|
|
502
517
|
const stateCookieName = pickName(config.stateCookieName, DEFAULT_STATE_COOKIE_NAME);
|
|
518
|
+
const nonceCookieName = pickName(config.nonceCookieName, DEFAULT_NONCE_COOKIE_NAME);
|
|
503
519
|
const returnUrlCookieName = pickName(config.returnUrlCookieName, DEFAULT_RETURN_COOKIE_NAME);
|
|
504
520
|
const verifierCookieName = pickName(config.verifierCookieName, DEFAULT_VERIFIER_COOKIE_NAME);
|
|
505
521
|
const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
|
|
506
522
|
const normalizedServerBase = (config.serverBaseUrl ?? ssoBaseUrl).replace(/\/+$/, "");
|
|
507
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) {
|
|
508
531
|
const { searchParams } = request.nextUrl;
|
|
509
532
|
const error = searchParams.get("error");
|
|
510
533
|
if (error) {
|
|
@@ -535,7 +558,7 @@ function createCallbackRouteHandler(config) {
|
|
|
535
558
|
{ status: 400 }
|
|
536
559
|
);
|
|
537
560
|
}
|
|
538
|
-
if (returnedState
|
|
561
|
+
if (!timingSafeEqualString(returnedState ?? "", savedState)) {
|
|
539
562
|
return NextResponse2.json(
|
|
540
563
|
{
|
|
541
564
|
error: "invalid_request",
|
|
@@ -544,6 +567,7 @@ function createCallbackRouteHandler(config) {
|
|
|
544
567
|
{ status: 400 }
|
|
545
568
|
);
|
|
546
569
|
}
|
|
570
|
+
const expectedNonce = request.cookies.get(nonceCookieName)?.value;
|
|
547
571
|
const verifier = request.cookies.get(verifierCookieName)?.value;
|
|
548
572
|
if (!verifier) {
|
|
549
573
|
return NextResponse2.json(
|
|
@@ -625,7 +649,8 @@ function createCallbackRouteHandler(config) {
|
|
|
625
649
|
tokenData.id_token,
|
|
626
650
|
tokenData.access_token,
|
|
627
651
|
normalizedBase,
|
|
628
|
-
clientId
|
|
652
|
+
clientId,
|
|
653
|
+
{ expectedNonce }
|
|
629
654
|
);
|
|
630
655
|
} catch (err) {
|
|
631
656
|
return NextResponse2.json(
|
|
@@ -669,11 +694,12 @@ function createCallbackRouteHandler(config) {
|
|
|
669
694
|
}
|
|
670
695
|
}
|
|
671
696
|
response.cookies.set(stateCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
697
|
+
response.cookies.set(nonceCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
672
698
|
response.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
673
699
|
response.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/", secureCookies));
|
|
674
700
|
response.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, request.nextUrl.pathname, secureCookies));
|
|
675
701
|
return response;
|
|
676
|
-
}
|
|
702
|
+
}
|
|
677
703
|
}
|
|
678
704
|
|
|
679
705
|
// src/next/logout.ts
|
|
@@ -681,7 +707,7 @@ import { NextResponse as NextResponse3 } from "next/server";
|
|
|
681
707
|
|
|
682
708
|
// src/core/discovery.ts
|
|
683
709
|
var DISCOVERY_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
684
|
-
var
|
|
710
|
+
var FETCH_TIMEOUT_MS2 = 5e3;
|
|
685
711
|
var cache = /* @__PURE__ */ new Map();
|
|
686
712
|
var inflight = /* @__PURE__ */ new Map();
|
|
687
713
|
function fetchDiscoveryCached(baseUrl) {
|
|
@@ -693,7 +719,7 @@ function fetchDiscoveryCached(baseUrl) {
|
|
|
693
719
|
if (existing) return existing;
|
|
694
720
|
const promise = (async () => {
|
|
695
721
|
const controller = new AbortController();
|
|
696
|
-
const timeoutId = setTimeout(() => controller.abort(),
|
|
722
|
+
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS2);
|
|
697
723
|
try {
|
|
698
724
|
const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`, {
|
|
699
725
|
signal: controller.signal
|
|
@@ -714,6 +740,26 @@ function fetchDiscoveryCached(baseUrl) {
|
|
|
714
740
|
}
|
|
715
741
|
|
|
716
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
|
+
<label style="display:flex;align-items:center;justify-content:center;gap:6px;margin:12px 0;font-size:14px;color:#374151;cursor:pointer;">
|
|
755
|
+
<input type="checkbox" name="global" value="1">\u540C\u65F6\u9000\u51FA\u6240\u6709 NIHPLOD \u5E73\u53F0
|
|
756
|
+
</label>
|
|
757
|
+
<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>
|
|
758
|
+
<p><a href="/" style="color:#2563eb;text-decoration:underline;">\u53D6\u6D88\u5E76\u8FD4\u56DE\u9996\u9875</a></p>
|
|
759
|
+
</form>
|
|
760
|
+
</body>
|
|
761
|
+
</html>`;
|
|
762
|
+
}
|
|
717
763
|
function generateRandomString2(length) {
|
|
718
764
|
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
|
|
719
765
|
const maxValid = Math.floor(256 / chars.length) * chars.length;
|
|
@@ -735,10 +781,18 @@ function createLogoutRouteHandler(config) {
|
|
|
735
781
|
redirectUri,
|
|
736
782
|
clientSecret,
|
|
737
783
|
postLogoutRedirectUri = new URL(redirectUri).origin + "/",
|
|
738
|
-
redirectToSso = true,
|
|
739
784
|
callbackPath = "/api/auth/callback",
|
|
740
785
|
insecureLocalDev: insecureLocalDevOpt = false
|
|
741
786
|
} = config;
|
|
787
|
+
let defaultScope = config.defaultScope ?? "local";
|
|
788
|
+
if (config.redirectToSso !== void 0) {
|
|
789
|
+
console.warn(
|
|
790
|
+
'[SSO SDK] redirectToSso \u5DF2\u5F03\u7528\uFF0C\u8BF7\u6539\u7528 defaultScope\uFF08redirectToSso: true \u2192 defaultScope: "global"\uFF0Cfalse \u2192 "local"\uFF09\u3002\u6CE8\u610F\uFF1A\u9ED8\u8BA4\u9000\u51FA\u8303\u56F4\u5DF2\u53D8\u66F4\u4E3A "local"\uFF08\u4EC5\u9000\u51FA\u672C\u7AD9\uFF0C\u4E0D\u8DF3\u8F6C SSO \u4E2D\u5FC3\uFF09\u3002'
|
|
791
|
+
);
|
|
792
|
+
if (config.defaultScope === void 0) {
|
|
793
|
+
defaultScope = config.redirectToSso ? "global" : "local";
|
|
794
|
+
}
|
|
795
|
+
}
|
|
742
796
|
const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
|
|
743
797
|
const secureCookies = !insecureLocalDev;
|
|
744
798
|
const pickName = (explicit, fallback) => insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : explicit ?? fallback;
|
|
@@ -746,6 +800,7 @@ function createLogoutRouteHandler(config) {
|
|
|
746
800
|
const refreshTokenCookieName = pickName(config.refreshTokenCookieName, DEFAULT_REFRESH_TOKEN_COOKIE_NAME);
|
|
747
801
|
const idTokenCookieName = pickName(config.idTokenCookieName, DEFAULT_ID_TOKEN_COOKIE_NAME);
|
|
748
802
|
const stateCookieName = pickName(config.stateCookieName, DEFAULT_STATE_COOKIE_NAME);
|
|
803
|
+
const nonceCookieName = pickName(config.nonceCookieName, DEFAULT_NONCE_COOKIE_NAME);
|
|
749
804
|
const returnUrlCookieName = pickName(config.returnUrlCookieName, DEFAULT_RETURN_COOKIE_NAME);
|
|
750
805
|
const verifierCookieName = pickName(config.verifierCookieName, DEFAULT_VERIFIER_COOKIE_NAME);
|
|
751
806
|
const logoutStateCookieName = pickName(config.logoutStateCookieName, DEFAULT_LOGOUT_STATE_COOKIE_NAME);
|
|
@@ -766,6 +821,18 @@ function createLogoutRouteHandler(config) {
|
|
|
766
821
|
res.cookies.set(logoutStateCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
767
822
|
return res;
|
|
768
823
|
}
|
|
824
|
+
if (request.method === "GET") {
|
|
825
|
+
return new NextResponse3(buildLogoutConfirmHtml(), {
|
|
826
|
+
status: 200,
|
|
827
|
+
headers: { "content-type": "text/html; charset=utf-8" }
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
let formGlobal = null;
|
|
831
|
+
try {
|
|
832
|
+
formGlobal = new URLSearchParams(await request.text()).get("global");
|
|
833
|
+
} catch {
|
|
834
|
+
}
|
|
835
|
+
const effectiveScope = formGlobal !== null ? formGlobal === "1" || formGlobal === "true" ? "global" : "local" : defaultScope;
|
|
769
836
|
const refreshToken = request.cookies.get(refreshTokenCookieName)?.value;
|
|
770
837
|
const idTokenHint = request.cookies.get(idTokenCookieName)?.value;
|
|
771
838
|
if (refreshToken) {
|
|
@@ -794,12 +861,13 @@ function createLogoutRouteHandler(config) {
|
|
|
794
861
|
res.cookies.set(refreshTokenCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
795
862
|
res.cookies.set(idTokenCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
796
863
|
res.cookies.set(stateCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
864
|
+
res.cookies.set(nonceCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
797
865
|
res.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
798
866
|
res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/", secureCookies));
|
|
799
867
|
res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, callbackPath, secureCookies));
|
|
800
868
|
return res;
|
|
801
869
|
};
|
|
802
|
-
if (
|
|
870
|
+
if (effectiveScope === "global") {
|
|
803
871
|
const discovery = await fetchDiscoveryCached(normalizedServerBase);
|
|
804
872
|
const endSessionEndpoint = discovery?.end_session_endpoint || `${normalizedBase}/api/oauth/end-session`;
|
|
805
873
|
const logoutUrl = new URL(endSessionEndpoint);
|
|
@@ -822,14 +890,208 @@ function createLogoutRouteHandler(config) {
|
|
|
822
890
|
);
|
|
823
891
|
};
|
|
824
892
|
}
|
|
893
|
+
|
|
894
|
+
// src/next/backchannel-logout.ts
|
|
895
|
+
import { NextResponse as NextResponse4 } from "next/server";
|
|
896
|
+
|
|
897
|
+
// src/core/logout-token.ts
|
|
898
|
+
var BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
|
|
899
|
+
var JTI_CACHE_CAPACITY = 1e3;
|
|
900
|
+
var seenJti = /* @__PURE__ */ new Map();
|
|
901
|
+
function recordJti(jti, expiresAtMs) {
|
|
902
|
+
const now = Date.now();
|
|
903
|
+
const existing = seenJti.get(jti);
|
|
904
|
+
if (existing !== void 0 && existing > now) return false;
|
|
905
|
+
for (const [key, exp] of seenJti) {
|
|
906
|
+
if (exp <= now) seenJti.delete(key);
|
|
907
|
+
}
|
|
908
|
+
while (seenJti.size >= JTI_CACHE_CAPACITY) {
|
|
909
|
+
const oldest = seenJti.keys().next().value;
|
|
910
|
+
if (oldest === void 0) break;
|
|
911
|
+
seenJti.delete(oldest);
|
|
912
|
+
}
|
|
913
|
+
seenJti.set(jti, expiresAtMs);
|
|
914
|
+
return true;
|
|
915
|
+
}
|
|
916
|
+
async function verifyLogoutToken(logoutToken, ssoBaseUrl, clientId) {
|
|
917
|
+
const baseUrl = ssoBaseUrl.replace(/\/+$/, "");
|
|
918
|
+
const header = decodeJwtHeader(logoutToken);
|
|
919
|
+
if (!header) {
|
|
920
|
+
throw new SsoError("logout_token_invalid", "Logout Token \u683C\u5F0F\u9519\u8BEF");
|
|
921
|
+
}
|
|
922
|
+
if (header.alg !== "RS256") {
|
|
923
|
+
throw new SsoError(
|
|
924
|
+
"logout_token_unsupported_alg",
|
|
925
|
+
`\u4E0D\u652F\u6301\u7684 Logout Token \u7B7E\u540D\u7B97\u6CD5: ${String(header.alg)}`
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
929
|
+
const normalizedIssuer = (discovery?.issuer || baseUrl).replace(/\/+$/, "");
|
|
930
|
+
const jwks = await fetchJwks(baseUrl);
|
|
931
|
+
if (!jwks) {
|
|
932
|
+
throw new SsoError(
|
|
933
|
+
"logout_token_invalid_signature",
|
|
934
|
+
"\u65E0\u6CD5\u83B7\u53D6 JWKS \u9A8C\u8BC1 Logout Token \u7B7E\u540D"
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
const kid = typeof header.kid === "string" ? header.kid : void 0;
|
|
938
|
+
const matchCandidates = (set) => set.keys.filter(
|
|
939
|
+
(k) => k.kty === "RSA" && k.alg === "RS256" && k.use === "sig" && (kid ? k.kid === kid : true)
|
|
940
|
+
);
|
|
941
|
+
const verifyAny = async (keys) => {
|
|
942
|
+
for (const key of keys) {
|
|
943
|
+
if (await verifyRs256Signature(logoutToken, key)) return true;
|
|
944
|
+
}
|
|
945
|
+
return false;
|
|
946
|
+
};
|
|
947
|
+
let candidates = matchCandidates(jwks);
|
|
948
|
+
let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
|
|
949
|
+
if (candidates.length === 0 || !validSig) {
|
|
950
|
+
const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });
|
|
951
|
+
if (freshJwks) {
|
|
952
|
+
candidates = matchCandidates(freshJwks);
|
|
953
|
+
validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
if (candidates.length === 0) {
|
|
957
|
+
throw new SsoError(
|
|
958
|
+
"logout_token_invalid_signature",
|
|
959
|
+
"JWKS \u4E2D\u672A\u627E\u5230\u5339\u914D\u7684 RS256 \u516C\u94A5"
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
if (!validSig) {
|
|
963
|
+
throw new SsoError(
|
|
964
|
+
"logout_token_invalid_signature",
|
|
965
|
+
"Logout Token \u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25"
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
const payload = decodeJwtPayload(logoutToken);
|
|
969
|
+
if (!payload) {
|
|
970
|
+
throw new SsoError("logout_token_invalid", "Logout Token payload \u89E3\u6790\u5931\u8D25");
|
|
971
|
+
}
|
|
972
|
+
const tokenIssuer = typeof payload.iss === "string" ? payload.iss.replace(/\/+$/, "") : "";
|
|
973
|
+
if (tokenIssuer !== normalizedIssuer) {
|
|
974
|
+
throw new SsoError(
|
|
975
|
+
"logout_token_issuer_mismatch",
|
|
976
|
+
"Logout Token issuer \u4E0D\u5339\u914D"
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
const aud = payload.aud;
|
|
980
|
+
const audList = Array.isArray(aud) ? aud : typeof aud === "string" ? [aud] : [];
|
|
981
|
+
if (!audList.includes(clientId)) {
|
|
982
|
+
throw new SsoError(
|
|
983
|
+
"logout_token_audience_mismatch",
|
|
984
|
+
"Logout Token audience \u4E0D\u5339\u914D"
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
if (typeof payload.exp !== "number") {
|
|
988
|
+
throw new SsoError("logout_token_invalid", "Logout Token \u7F3A\u5C11 exp \u58F0\u660E");
|
|
989
|
+
}
|
|
990
|
+
if (Date.now() >= payload.exp * 1e3 + 6e4) {
|
|
991
|
+
throw new SsoError("logout_token_expired", "Logout Token \u5DF2\u8FC7\u671F");
|
|
992
|
+
}
|
|
993
|
+
const events = payload.events;
|
|
994
|
+
if (!events || typeof events !== "object" || !(BACKCHANNEL_LOGOUT_EVENT in events)) {
|
|
995
|
+
throw new SsoError(
|
|
996
|
+
"logout_token_invalid",
|
|
997
|
+
"Logout Token \u7F3A\u5C11 backchannel-logout events \u58F0\u660E"
|
|
998
|
+
);
|
|
999
|
+
}
|
|
1000
|
+
const sub = typeof payload.sub === "string" && payload.sub ? payload.sub : void 0;
|
|
1001
|
+
const sid = typeof payload.sid === "string" && payload.sid ? payload.sid : void 0;
|
|
1002
|
+
if (!sub && !sid) {
|
|
1003
|
+
throw new SsoError(
|
|
1004
|
+
"logout_token_invalid",
|
|
1005
|
+
"Logout Token \u5FC5\u987B\u5305\u542B sub \u6216 sid \u81F3\u5C11\u5176\u4E00"
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
const jti = typeof payload.jti === "string" ? payload.jti : "";
|
|
1009
|
+
if (!jti) {
|
|
1010
|
+
throw new SsoError("logout_token_invalid", "Logout Token \u7F3A\u5C11 jti \u58F0\u660E");
|
|
1011
|
+
}
|
|
1012
|
+
if (!recordJti(jti, payload.exp * 1e3 + 6e4)) {
|
|
1013
|
+
throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
|
|
1014
|
+
}
|
|
1015
|
+
return { sub, sid };
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// src/next/backchannel-logout.ts
|
|
1019
|
+
function createBackchannelLogoutRouteHandler(config) {
|
|
1020
|
+
const { clientId, ssoBaseUrl, onLogout } = config;
|
|
1021
|
+
const insecureLocalDev = resolveInsecureLocalDev(
|
|
1022
|
+
config.insecureLocalDev ?? false,
|
|
1023
|
+
ssoBaseUrl
|
|
1024
|
+
);
|
|
1025
|
+
const secureCookies = !insecureLocalDev;
|
|
1026
|
+
const pickName = (explicit, fallback) => insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : explicit ?? fallback;
|
|
1027
|
+
const accessTokenCookieName = pickName(
|
|
1028
|
+
config.accessTokenCookieName,
|
|
1029
|
+
DEFAULT_ACCESS_TOKEN_COOKIE_NAME
|
|
1030
|
+
);
|
|
1031
|
+
const refreshTokenCookieName = pickName(
|
|
1032
|
+
config.refreshTokenCookieName,
|
|
1033
|
+
DEFAULT_REFRESH_TOKEN_COOKIE_NAME
|
|
1034
|
+
);
|
|
1035
|
+
const idTokenCookieName = pickName(
|
|
1036
|
+
config.idTokenCookieName,
|
|
1037
|
+
DEFAULT_ID_TOKEN_COOKIE_NAME
|
|
1038
|
+
);
|
|
1039
|
+
return async function handler(request) {
|
|
1040
|
+
if (request.method !== "POST") {
|
|
1041
|
+
return NextResponse4.json(
|
|
1042
|
+
{ error: "method_not_allowed", error_description: "\u4EC5\u63A5\u53D7 POST \u8BF7\u6C42" },
|
|
1043
|
+
{ status: 405 }
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
const logoutToken = new URLSearchParams(await request.text()).get(
|
|
1047
|
+
"logout_token"
|
|
1048
|
+
);
|
|
1049
|
+
if (!logoutToken) {
|
|
1050
|
+
return NextResponse4.json(
|
|
1051
|
+
{ error: "invalid_request", error_description: "\u7F3A\u5C11 logout_token" },
|
|
1052
|
+
{ status: 400 }
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
let payload;
|
|
1056
|
+
try {
|
|
1057
|
+
payload = await verifyLogoutToken(logoutToken, ssoBaseUrl, clientId);
|
|
1058
|
+
} catch (err) {
|
|
1059
|
+
const code = err instanceof SsoError ? err.code : "logout_token_invalid";
|
|
1060
|
+
const description = err instanceof SsoError ? err.description : "Logout Token \u9A8C\u8BC1\u5931\u8D25";
|
|
1061
|
+
return NextResponse4.json(
|
|
1062
|
+
{ error: code, error_description: description },
|
|
1063
|
+
{ status: 400 }
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
try {
|
|
1067
|
+
await onLogout?.(payload, request);
|
|
1068
|
+
} catch (err) {
|
|
1069
|
+
console.error(
|
|
1070
|
+
"[SSO SDK] backchannel logout onLogout \u94A9\u5B50\u6267\u884C\u5931\u8D25:",
|
|
1071
|
+
err
|
|
1072
|
+
);
|
|
1073
|
+
return NextResponse4.json(
|
|
1074
|
+
{ error: "server_error", error_description: "onLogout \u5904\u7406\u5931\u8D25" },
|
|
1075
|
+
{ status: 500 }
|
|
1076
|
+
);
|
|
1077
|
+
}
|
|
1078
|
+
const res = new NextResponse4(null, { status: 200 });
|
|
1079
|
+
res.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
1080
|
+
res.cookies.set(refreshTokenCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
1081
|
+
res.cookies.set(idTokenCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
1082
|
+
return res;
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
825
1085
|
export {
|
|
826
1086
|
DEFAULT_ACCESS_TOKEN_COOKIE_NAME,
|
|
827
1087
|
DEFAULT_ID_TOKEN_COOKIE_NAME,
|
|
828
1088
|
DEFAULT_LOGOUT_STATE_COOKIE_NAME,
|
|
1089
|
+
DEFAULT_NONCE_COOKIE_NAME,
|
|
829
1090
|
DEFAULT_REFRESH_TOKEN_COOKIE_NAME,
|
|
830
1091
|
DEFAULT_RETURN_COOKIE_NAME,
|
|
831
1092
|
DEFAULT_STATE_COOKIE_NAME,
|
|
832
1093
|
DEFAULT_VERIFIER_COOKIE_NAME,
|
|
1094
|
+
createBackchannelLogoutRouteHandler,
|
|
833
1095
|
createCallbackRouteHandler,
|
|
834
1096
|
createLogoutRouteHandler,
|
|
835
1097
|
createSsoMiddleware,
|