@nihplod/sso-sdk 1.4.0 → 1.5.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 +90 -8
- package/dist/index.d.mts +27 -3
- package/dist/index.d.ts +27 -3
- package/dist/index.js +169 -38
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +169 -38
- package/dist/index.mjs.map +1 -1
- package/dist/next/index.d.mts +50 -0
- package/dist/next/index.d.ts +50 -0
- package/dist/next/index.js +320 -103
- package/dist/next/index.js.map +1 -1
- package/dist/next/index.mjs +320 -103
- package/dist/next/index.mjs.map +1 -1
- package/dist/react/index.d.mts +25 -1
- package/dist/react/index.d.ts +25 -1
- package/dist/react/index.js +329 -71
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +329 -71
- package/dist/react/index.mjs.map +1 -1
- package/package.json +9 -3
package/dist/next/index.js
CHANGED
|
@@ -122,6 +122,7 @@ function matchesPath(pathname, paths) {
|
|
|
122
122
|
var introspectionCache = /* @__PURE__ */ new Map();
|
|
123
123
|
var INTROSPECT_CACHE_TTL_MS = 3e4;
|
|
124
124
|
var INTROSPECT_CACHE_MAX_ENTRIES = 500;
|
|
125
|
+
var INTROSPECT_TIMEOUT_MS = 5e3;
|
|
125
126
|
async function introspectCacheKey(token, clientId) {
|
|
126
127
|
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
|
|
127
128
|
const hex = Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
@@ -154,7 +155,7 @@ async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret)
|
|
|
154
155
|
const cacheKey = await introspectCacheKey(token, clientId);
|
|
155
156
|
const cached = introspectCacheGet(cacheKey);
|
|
156
157
|
if (cached) {
|
|
157
|
-
return cached.active;
|
|
158
|
+
return cached.active ? "active" : "inactive";
|
|
158
159
|
}
|
|
159
160
|
try {
|
|
160
161
|
const body = new URLSearchParams({
|
|
@@ -168,21 +169,22 @@ async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret)
|
|
|
168
169
|
const res = await fetch(`${ssoBaseUrl}/api/oauth/introspect`, {
|
|
169
170
|
method: "POST",
|
|
170
171
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
171
|
-
body: body.toString()
|
|
172
|
+
body: body.toString(),
|
|
173
|
+
signal: AbortSignal.timeout(INTROSPECT_TIMEOUT_MS)
|
|
172
174
|
});
|
|
173
175
|
if (!res.ok) {
|
|
174
176
|
if (res.status !== 401 && res.status !== 403) {
|
|
175
|
-
return
|
|
177
|
+
return "unreachable";
|
|
176
178
|
}
|
|
177
179
|
introspectCacheSet(cacheKey, false);
|
|
178
|
-
return
|
|
180
|
+
return "inactive";
|
|
179
181
|
}
|
|
180
182
|
const data = await res.json();
|
|
181
183
|
const active = data.active === true;
|
|
182
184
|
introspectCacheSet(cacheKey, active);
|
|
183
|
-
return active;
|
|
185
|
+
return active ? "active" : "inactive";
|
|
184
186
|
} catch {
|
|
185
|
-
return
|
|
187
|
+
return "unreachable";
|
|
186
188
|
}
|
|
187
189
|
}
|
|
188
190
|
function createSsoMiddleware(config) {
|
|
@@ -194,8 +196,7 @@ function createSsoMiddleware(config) {
|
|
|
194
196
|
scopes = "openid profile",
|
|
195
197
|
publicPaths = [],
|
|
196
198
|
callbackPath = "/api/auth/callback",
|
|
197
|
-
|
|
198
|
-
validateSsoCookie = true,
|
|
199
|
+
failClosedOnIntrospectionError = false,
|
|
199
200
|
insecureLocalDev: insecureLocalDevOpt = false
|
|
200
201
|
} = config;
|
|
201
202
|
const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
|
|
@@ -207,9 +208,9 @@ function createSsoMiddleware(config) {
|
|
|
207
208
|
const verifierCookieName = insecureLocalDev ? toInsecureCookieName(config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME) : config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME;
|
|
208
209
|
const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
|
|
209
210
|
const normalizedServerBase = (config.serverBaseUrl ?? ssoBaseUrl).replace(/\/+$/, "");
|
|
210
|
-
if (
|
|
211
|
+
if (config.validateSsoCookie !== void 0 || config.ssoCookieName !== void 0) {
|
|
211
212
|
console.warn(
|
|
212
|
-
|
|
213
|
+
'[SSO SDK] validateSsoCookie / ssoCookieName \u5DF2\u5E9F\u5F03\uFF1A\u4E3B\u7AD9\u4F1A\u8BDD Cookie\uFF08__Host-user_token\uFF09\u662F\u4E3B\u7AD9\u5185\u90E8 token\uFF08type="user"\uFF09\uFF0Cintrospect \u7AEF\u70B9\u53EA\u63A5\u53D7 access_token\uFF0C\u6821\u9A8C\u5FC5\u7136\u5931\u8D25\uFF1B\u4E14 __Host- Cookie \u4E0D\u4F1A\u4E0B\u53D1\u5230\u5B50\u57DF\u3002\u8BE5\u5FEB\u901F\u901A\u9053\u4ECE\u672A\u751F\u6548\uFF0C\u5DF2\u79FB\u9664\u3002\u8BF7\u5220\u9664\u8FD9\u4E24\u4E2A\u914D\u7F6E\u9879\uFF1B\u4F1A\u8BDD\u68C0\u6D4B\u4EE5\u672C\u7AD9 access_token Cookie \u4E3A\u51C6\u3002'
|
|
213
214
|
);
|
|
214
215
|
}
|
|
215
216
|
if (!clientSecret) {
|
|
@@ -229,31 +230,30 @@ function createSsoMiddleware(config) {
|
|
|
229
230
|
if (matchesPath(pathname, allPublicPaths)) {
|
|
230
231
|
return import_server.NextResponse.next();
|
|
231
232
|
}
|
|
232
|
-
const ssoSession = request.cookies.get(ssoCookieName);
|
|
233
|
-
if (ssoSession?.value) {
|
|
234
|
-
if (validateSsoCookie) {
|
|
235
|
-
const tokenActive = await introspectAccessToken(
|
|
236
|
-
ssoSession.value,
|
|
237
|
-
normalizedServerBase,
|
|
238
|
-
clientId,
|
|
239
|
-
clientSecret
|
|
240
|
-
);
|
|
241
|
-
if (tokenActive) {
|
|
242
|
-
return import_server.NextResponse.next();
|
|
243
|
-
}
|
|
244
|
-
} else {
|
|
245
|
-
return import_server.NextResponse.next();
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
233
|
const accessTokenCookie = request.cookies.get(accessTokenCookieName);
|
|
249
234
|
if (accessTokenCookie?.value) {
|
|
250
|
-
const
|
|
235
|
+
const introspectResult = await introspectAccessToken(
|
|
251
236
|
accessTokenCookie.value,
|
|
252
237
|
normalizedServerBase,
|
|
253
238
|
clientId,
|
|
254
239
|
clientSecret
|
|
255
240
|
);
|
|
256
|
-
if (
|
|
241
|
+
if (introspectResult === "active") {
|
|
242
|
+
return import_server.NextResponse.next();
|
|
243
|
+
}
|
|
244
|
+
if (introspectResult === "unreachable") {
|
|
245
|
+
if (failClosedOnIntrospectionError) {
|
|
246
|
+
console.warn(
|
|
247
|
+
"[SSO SDK] introspection \u4E0D\u53EF\u8FBE\uFF08\u7F51\u7EDC\u5F02\u5E38/\u8D85\u65F6/5xx\uFF09\uFF0CfailClosedOnIntrospectionError=true\uFF0C\u5BF9\u6301\u6709 SSO access token Cookie \u7684\u8BF7\u6C42 fail-closed \u8FD4\u56DE 502\u3002"
|
|
248
|
+
);
|
|
249
|
+
return new import_server.NextResponse(
|
|
250
|
+
"SSO \u8BA4\u8BC1\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF08introspection unreachable\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
|
|
251
|
+
{ status: 502 }
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
console.warn(
|
|
255
|
+
"[SSO SDK] introspection \u4E0D\u53EF\u8FBE\uFF08\u7F51\u7EDC\u5F02\u5E38/\u8D85\u65F6/5xx\uFF09\uFF0C\u5BF9\u6301\u6709 SSO access token Cookie \u7684\u8BF7\u6C42 fail-open \u653E\u884C\uFF1B\u654F\u611F\u6570\u636E\u7684\u9274\u6743\u7531 Route Handler \u515C\u5E95\u3002"
|
|
256
|
+
);
|
|
257
257
|
return import_server.NextResponse.next();
|
|
258
258
|
}
|
|
259
259
|
}
|
|
@@ -273,11 +273,28 @@ function createSsoMiddleware(config) {
|
|
|
273
273
|
const loginUrl = new URL("/api/oauth/authorize", normalizedBase);
|
|
274
274
|
loginUrl.search = authorizeParams.toString();
|
|
275
275
|
const response = import_server.NextResponse.redirect(loginUrl);
|
|
276
|
-
|
|
277
|
-
response.cookies.set(
|
|
278
|
-
|
|
276
|
+
const attemptSuffix = `_${state}`;
|
|
277
|
+
response.cookies.set(
|
|
278
|
+
`${stateCookieName}${attemptSuffix}`,
|
|
279
|
+
state,
|
|
280
|
+
getHostCookieOptions(600, secureCookies)
|
|
281
|
+
);
|
|
282
|
+
response.cookies.set(
|
|
283
|
+
`${nonceCookieName}${attemptSuffix}`,
|
|
284
|
+
nonce,
|
|
285
|
+
getHostCookieOptions(600, secureCookies)
|
|
286
|
+
);
|
|
287
|
+
response.cookies.set(
|
|
288
|
+
`${verifierCookieName}${attemptSuffix}`,
|
|
289
|
+
verifier,
|
|
290
|
+
getSecureCookieOptions(600, callbackPath, secureCookies)
|
|
291
|
+
);
|
|
279
292
|
const safeReturnUrl = (request.nextUrl.pathname + request.nextUrl.search).slice(0, 2048);
|
|
280
|
-
response.cookies.set(
|
|
293
|
+
response.cookies.set(
|
|
294
|
+
`${returnUrlCookieName}${attemptSuffix}`,
|
|
295
|
+
safeReturnUrl,
|
|
296
|
+
getHostCookieOptions(600, secureCookies)
|
|
297
|
+
);
|
|
281
298
|
if (accessTokenCookie?.value) {
|
|
282
299
|
response.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
283
300
|
}
|
|
@@ -300,12 +317,17 @@ var SsoError = class extends Error {
|
|
|
300
317
|
};
|
|
301
318
|
|
|
302
319
|
// src/core/security.ts
|
|
320
|
+
var UNSAFE_URL_CHAR_PATTERN = /[\\\u0000-\u001F\u007F]/;
|
|
303
321
|
function isTrustedReturnUrl(url, currentOrigin) {
|
|
304
322
|
if (!url) return false;
|
|
305
|
-
if (
|
|
306
|
-
if (url.startsWith("
|
|
323
|
+
if (UNSAFE_URL_CHAR_PATTERN.test(url)) return false;
|
|
324
|
+
if (url.startsWith("//")) return false;
|
|
325
|
+
if (url.startsWith("/")) return true;
|
|
307
326
|
try {
|
|
308
|
-
|
|
327
|
+
const parsed = new URL(url);
|
|
328
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
|
|
329
|
+
if (parsed.username || parsed.password) return false;
|
|
330
|
+
return parsed.origin === currentOrigin;
|
|
309
331
|
} catch {
|
|
310
332
|
return false;
|
|
311
333
|
}
|
|
@@ -539,6 +561,92 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
|
|
|
539
561
|
}
|
|
540
562
|
|
|
541
563
|
// src/next/callback.ts
|
|
564
|
+
function escapeHtml(value) {
|
|
565
|
+
return value.replace(/[&<>"']/g, (ch) => {
|
|
566
|
+
switch (ch) {
|
|
567
|
+
case "&":
|
|
568
|
+
return "&";
|
|
569
|
+
case "<":
|
|
570
|
+
return "<";
|
|
571
|
+
case ">":
|
|
572
|
+
return ">";
|
|
573
|
+
case '"':
|
|
574
|
+
return """;
|
|
575
|
+
default:
|
|
576
|
+
return "'";
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
function buildErrorPage(status, error, errorDescription) {
|
|
581
|
+
const safeDescription = escapeHtml(errorDescription);
|
|
582
|
+
const safeError = escapeHtml(error);
|
|
583
|
+
return `<!doctype html>
|
|
584
|
+
<html lang="zh-CN">
|
|
585
|
+
<head>
|
|
586
|
+
<meta charset="utf-8" />
|
|
587
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
588
|
+
<meta name="robots" content="noindex" />
|
|
589
|
+
<title>\u767B\u5F55\u5931\u8D25</title>
|
|
590
|
+
<style>
|
|
591
|
+
:root { color-scheme: light; }
|
|
592
|
+
body { margin: 0; font-family: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; background: #fafafa; color: #2c2c2c; }
|
|
593
|
+
main { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem; text-align: center; }
|
|
594
|
+
h1 { font-size: 1.25rem; font-weight: 500; letter-spacing: 0.08em; margin: 0 0 0.75rem; }
|
|
595
|
+
p { margin: 0 0 1.5rem; color: #6b7280; font-size: 0.875rem; line-height: 1.6; max-width: 28rem; }
|
|
596
|
+
.actions { display: flex; gap: 0.75rem; }
|
|
597
|
+
a { display: inline-block; padding: 0.6rem 1.4rem; font-size: 0.8125rem; letter-spacing: 0.08em; text-decoration: none; }
|
|
598
|
+
a.primary { background: #2c2c2c; color: #fff; }
|
|
599
|
+
a.secondary { border: 1px solid rgba(44, 44, 44, 0.25); color: #2c2c2c; }
|
|
600
|
+
.code { margin-top: 2rem; font-size: 0.6875rem; color: #9ca3af; }
|
|
601
|
+
</style>
|
|
602
|
+
</head>
|
|
603
|
+
<body>
|
|
604
|
+
<main>
|
|
605
|
+
<h1>\u767B\u5F55\u5931\u8D25</h1>
|
|
606
|
+
<p>${safeDescription}</p>
|
|
607
|
+
<div class="actions">
|
|
608
|
+
<a class="primary" href="/">\u91CD\u65B0\u767B\u5F55</a>
|
|
609
|
+
<a class="secondary" href="/">\u8FD4\u56DE\u9996\u9875</a>
|
|
610
|
+
</div>
|
|
611
|
+
<p class="code">\u9519\u8BEF\u7801\uFF1A${safeError}\uFF08\u53CD\u9988\u95EE\u9898\u65F6\u8BF7\u9644\u4E0A\uFF09</p>
|
|
612
|
+
</main>
|
|
613
|
+
</body>
|
|
614
|
+
</html>`;
|
|
615
|
+
}
|
|
616
|
+
function buildErrorResponse(request, status, error, errorDescription, custom) {
|
|
617
|
+
if (custom) {
|
|
618
|
+
const handled = custom({ error, errorDescription, status, request });
|
|
619
|
+
if (handled instanceof Promise) {
|
|
620
|
+
return handled.then(
|
|
621
|
+
(res) => res ? toNextResponse(res) : defaultErrorResponse(request, status, error, errorDescription)
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
if (handled) return toNextResponse(handled);
|
|
625
|
+
}
|
|
626
|
+
return defaultErrorResponse(request, status, error, errorDescription);
|
|
627
|
+
}
|
|
628
|
+
function toNextResponse(res) {
|
|
629
|
+
if (res instanceof import_server2.NextResponse) return res;
|
|
630
|
+
return new import_server2.NextResponse(res.body, {
|
|
631
|
+
status: res.status,
|
|
632
|
+
statusText: res.statusText,
|
|
633
|
+
headers: res.headers
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
function defaultErrorResponse(request, status, error, errorDescription) {
|
|
637
|
+
const accept = request.headers.get("accept") ?? "";
|
|
638
|
+
const wantsHtml = accept.includes("text/html") && request.nextUrl.searchParams.get("format") !== "json";
|
|
639
|
+
if (wantsHtml) {
|
|
640
|
+
return new import_server2.NextResponse(buildErrorPage(status, error, errorDescription), {
|
|
641
|
+
status,
|
|
642
|
+
headers: {
|
|
643
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
644
|
+
"Cache-Control": "no-store"
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
return import_server2.NextResponse.json({ error, error_description: errorDescription }, { status });
|
|
649
|
+
}
|
|
542
650
|
function createCallbackRouteHandler(config) {
|
|
543
651
|
const {
|
|
544
652
|
clientId,
|
|
@@ -546,6 +654,7 @@ function createCallbackRouteHandler(config) {
|
|
|
546
654
|
redirectUri,
|
|
547
655
|
clientSecret,
|
|
548
656
|
defaultReturnPath = "/",
|
|
657
|
+
scopes,
|
|
549
658
|
insecureLocalDev: insecureLocalDevOpt = false
|
|
550
659
|
} = config;
|
|
551
660
|
const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
|
|
@@ -563,7 +672,21 @@ function createCallbackRouteHandler(config) {
|
|
|
563
672
|
return async function GET(request) {
|
|
564
673
|
const response = await handleCallback(request);
|
|
565
674
|
if (response.status >= 400) {
|
|
675
|
+
if (config.debug) {
|
|
676
|
+
console.warn(
|
|
677
|
+
`[SSO SDK] \u56DE\u8C03\u5931\u8D25 status=${response.status}`,
|
|
678
|
+
request.nextUrl.searchParams.get("error") ?? ""
|
|
679
|
+
);
|
|
680
|
+
}
|
|
566
681
|
response.cookies.set(nonceCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
682
|
+
const returnedState = request.nextUrl.searchParams.get("state");
|
|
683
|
+
if (returnedState && /^[A-Za-z0-9\-._~]{8,512}$/.test(returnedState)) {
|
|
684
|
+
response.cookies.set(
|
|
685
|
+
`${nonceCookieName}_${returnedState}`,
|
|
686
|
+
"",
|
|
687
|
+
getHostCookieOptions(0, secureCookies)
|
|
688
|
+
);
|
|
689
|
+
}
|
|
567
690
|
}
|
|
568
691
|
return response;
|
|
569
692
|
};
|
|
@@ -572,50 +695,75 @@ function createCallbackRouteHandler(config) {
|
|
|
572
695
|
const error = searchParams.get("error");
|
|
573
696
|
if (error) {
|
|
574
697
|
const desc = searchParams.get("error_description") || error;
|
|
575
|
-
return
|
|
576
|
-
|
|
577
|
-
|
|
698
|
+
return buildErrorResponse(
|
|
699
|
+
request,
|
|
700
|
+
400,
|
|
701
|
+
"authorization_failed",
|
|
702
|
+
desc,
|
|
703
|
+
config.renderErrorPage
|
|
578
704
|
);
|
|
579
705
|
}
|
|
580
706
|
const code = searchParams.get("code");
|
|
581
707
|
const returnedState = searchParams.get("state");
|
|
582
708
|
if (!code) {
|
|
583
|
-
return
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
709
|
+
return buildErrorResponse(
|
|
710
|
+
request,
|
|
711
|
+
400,
|
|
712
|
+
"invalid_request",
|
|
713
|
+
"\u767B\u5F55\u4FE1\u606F\u4E0D\u5B8C\u6574\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u767B\u5F55",
|
|
714
|
+
config.renderErrorPage
|
|
589
715
|
);
|
|
590
716
|
}
|
|
591
|
-
|
|
717
|
+
if (!returnedState || !/^[A-Za-z0-9\-._~]{8,512}$/.test(returnedState)) {
|
|
718
|
+
return buildErrorResponse(
|
|
719
|
+
request,
|
|
720
|
+
400,
|
|
721
|
+
"invalid_request",
|
|
722
|
+
"State \u53C2\u6570\u7F3A\u5931\u6216\u683C\u5F0F\u975E\u6CD5\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42",
|
|
723
|
+
config.renderErrorPage
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
const attemptSuffix = `_${returnedState}`;
|
|
727
|
+
const readTransientCookie = (name) => request.cookies.get(`${name}${attemptSuffix}`)?.value ?? request.cookies.get(name)?.value;
|
|
728
|
+
const savedState = readTransientCookie(stateCookieName);
|
|
592
729
|
if (!savedState) {
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
730
|
+
const alternateName = secureCookies ? toInsecureCookieName(stateCookieName) : DEFAULT_STATE_COOKIE_NAME;
|
|
731
|
+
const alternateValue = request.cookies.get(`${alternateName}${attemptSuffix}`)?.value ?? request.cookies.get(alternateName)?.value;
|
|
732
|
+
if (alternateValue) {
|
|
733
|
+
return buildErrorResponse(
|
|
734
|
+
request,
|
|
735
|
+
500,
|
|
736
|
+
"invalid_config",
|
|
737
|
+
"\u68C0\u6D4B\u5230 SSO SDK \u914D\u7F6E\u4E0D\u4E00\u81F4\uFF1Amiddleware \u4E0E callback \u7684 insecureLocalDev \u6216 Cookie \u540D\u79F0\u4E0D\u5339\u914D\uFF0C\u8BF7\u7EDF\u4E00\u914D\u7F6E",
|
|
738
|
+
config.renderErrorPage
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
return buildErrorResponse(
|
|
742
|
+
request,
|
|
743
|
+
400,
|
|
744
|
+
"invalid_request",
|
|
745
|
+
"\u767B\u5F55\u4F1A\u8BDD\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42",
|
|
746
|
+
config.renderErrorPage
|
|
599
747
|
);
|
|
600
748
|
}
|
|
601
|
-
if (!timingSafeEqualString(returnedState
|
|
602
|
-
return
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
749
|
+
if (!timingSafeEqualString(returnedState, savedState)) {
|
|
750
|
+
return buildErrorResponse(
|
|
751
|
+
request,
|
|
752
|
+
400,
|
|
753
|
+
"invalid_request",
|
|
754
|
+
"\u767B\u5F55\u4F1A\u8BDD\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42",
|
|
755
|
+
config.renderErrorPage
|
|
608
756
|
);
|
|
609
757
|
}
|
|
610
|
-
const expectedNonce =
|
|
611
|
-
const verifier =
|
|
758
|
+
const expectedNonce = readTransientCookie(nonceCookieName);
|
|
759
|
+
const verifier = readTransientCookie(verifierCookieName);
|
|
612
760
|
if (!verifier) {
|
|
613
|
-
return
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
761
|
+
return buildErrorResponse(
|
|
762
|
+
request,
|
|
763
|
+
400,
|
|
764
|
+
"invalid_request",
|
|
765
|
+
"\u767B\u5F55\u4F1A\u8BDD\u5DF2\u8FC7\u671F\uFF08\u53EF\u80FD\u5207\u6362\u4E86\u6807\u7B7E\u9875\uFF09\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u767B\u5F55",
|
|
766
|
+
config.renderErrorPage
|
|
619
767
|
);
|
|
620
768
|
}
|
|
621
769
|
const tokenEndpoint = `${normalizedServerBase}/api/oauth/token`;
|
|
@@ -646,17 +794,23 @@ function createCallbackRouteHandler(config) {
|
|
|
646
794
|
} catch (err) {
|
|
647
795
|
lastError = err;
|
|
648
796
|
if (attempt >= maxRetries) {
|
|
649
|
-
return
|
|
650
|
-
|
|
651
|
-
|
|
797
|
+
return buildErrorResponse(
|
|
798
|
+
request,
|
|
799
|
+
502,
|
|
800
|
+
"server_error",
|
|
801
|
+
"\u767B\u5F55\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF08Token \u8BF7\u6C42\u5931\u8D25\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
|
|
802
|
+
config.renderErrorPage
|
|
652
803
|
);
|
|
653
804
|
}
|
|
654
805
|
}
|
|
655
806
|
}
|
|
656
807
|
if (lastError || !res) {
|
|
657
|
-
return
|
|
658
|
-
|
|
659
|
-
|
|
808
|
+
return buildErrorResponse(
|
|
809
|
+
request,
|
|
810
|
+
502,
|
|
811
|
+
"server_error",
|
|
812
|
+
"\u767B\u5F55\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF08Token \u8BF7\u6C42\u5931\u8D25\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
|
|
813
|
+
config.renderErrorPage
|
|
660
814
|
);
|
|
661
815
|
}
|
|
662
816
|
if (!res.ok) {
|
|
@@ -665,22 +819,32 @@ function createCallbackRouteHandler(config) {
|
|
|
665
819
|
errData = await res.json();
|
|
666
820
|
} catch {
|
|
667
821
|
}
|
|
668
|
-
return
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
}
|
|
673
|
-
|
|
822
|
+
return buildErrorResponse(
|
|
823
|
+
request,
|
|
824
|
+
502,
|
|
825
|
+
"token_request_failed",
|
|
826
|
+
errData.error_description || `\u767B\u5F55\u5931\u8D25\uFF08Token \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5`,
|
|
827
|
+
config.renderErrorPage
|
|
674
828
|
);
|
|
675
829
|
}
|
|
676
830
|
const tokenData = await res.json();
|
|
677
|
-
if (!tokenData.access_token || !tokenData.refresh_token) {
|
|
678
|
-
return
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
831
|
+
if (!tokenData.access_token || !tokenData.refresh_token || typeof tokenData.expires_in !== "number" || !Number.isFinite(tokenData.expires_in) || tokenData.expires_in <= 0) {
|
|
832
|
+
return buildErrorResponse(
|
|
833
|
+
request,
|
|
834
|
+
502,
|
|
835
|
+
"server_error",
|
|
836
|
+
"\u767B\u5F55\u670D\u52A1\u8FD4\u56DE\u5F02\u5E38\uFF08Token \u54CD\u5E94\u4E0D\u5B8C\u6574\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
|
|
837
|
+
config.renderErrorPage
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
const requiresIdToken = scopes !== void 0 && scopes.split(" ").filter(Boolean).includes("openid");
|
|
841
|
+
if (requiresIdToken && !tokenData.id_token) {
|
|
842
|
+
return buildErrorResponse(
|
|
843
|
+
request,
|
|
844
|
+
400,
|
|
845
|
+
"id_token_invalid",
|
|
846
|
+
"\u767B\u5F55\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u767B\u5F55",
|
|
847
|
+
config.renderErrorPage
|
|
684
848
|
);
|
|
685
849
|
}
|
|
686
850
|
if (tokenData.id_token) {
|
|
@@ -693,17 +857,17 @@ function createCallbackRouteHandler(config) {
|
|
|
693
857
|
{ expectedNonce }
|
|
694
858
|
);
|
|
695
859
|
} catch (err) {
|
|
696
|
-
return
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
860
|
+
return buildErrorResponse(
|
|
861
|
+
request,
|
|
862
|
+
400,
|
|
863
|
+
"id_token_invalid",
|
|
864
|
+
err instanceof Error && /[\u4e00-\u9fff]/.test(err.message) ? err.message : "\u767B\u5F55\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u767B\u5F55",
|
|
865
|
+
config.renderErrorPage
|
|
702
866
|
);
|
|
703
867
|
}
|
|
704
868
|
}
|
|
705
869
|
const callbackOrigin = new URL(redirectUri).origin;
|
|
706
|
-
const rawReturnUrl =
|
|
870
|
+
const rawReturnUrl = readTransientCookie(returnUrlCookieName) || defaultReturnPath;
|
|
707
871
|
const returnUrl = isTrustedReturnUrl(rawReturnUrl, callbackOrigin) ? rawReturnUrl : "/";
|
|
708
872
|
const response = import_server2.NextResponse.redirect(new URL(returnUrl, callbackOrigin));
|
|
709
873
|
response.cookies.set(accessTokenCookieName, tokenData.access_token, {
|
|
@@ -733,11 +897,18 @@ function createCallbackRouteHandler(config) {
|
|
|
733
897
|
});
|
|
734
898
|
}
|
|
735
899
|
}
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
900
|
+
for (const name of [stateCookieName, nonceCookieName, returnUrlCookieName]) {
|
|
901
|
+
response.cookies.set(name, "", getHostCookieOptions(0, secureCookies));
|
|
902
|
+
response.cookies.set(`${name}${attemptSuffix}`, "", getHostCookieOptions(0, secureCookies));
|
|
903
|
+
}
|
|
904
|
+
for (const path of ["/", request.nextUrl.pathname]) {
|
|
905
|
+
response.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, path, secureCookies));
|
|
906
|
+
response.cookies.set(
|
|
907
|
+
`${verifierCookieName}${attemptSuffix}`,
|
|
908
|
+
"",
|
|
909
|
+
getSecureCookieOptions(0, path, secureCookies)
|
|
910
|
+
);
|
|
911
|
+
}
|
|
741
912
|
return response;
|
|
742
913
|
}
|
|
743
914
|
}
|
|
@@ -867,6 +1038,20 @@ function createLogoutRouteHandler(config) {
|
|
|
867
1038
|
headers: { "content-type": "text/html; charset=utf-8" }
|
|
868
1039
|
});
|
|
869
1040
|
}
|
|
1041
|
+
const requestOrigin = request.headers.get("origin");
|
|
1042
|
+
if (requestOrigin && requestOrigin !== callbackOrigin) {
|
|
1043
|
+
return import_server3.NextResponse.json(
|
|
1044
|
+
{ error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" },
|
|
1045
|
+
{ status: 403 }
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
const secFetchSite = request.headers.get("sec-fetch-site");
|
|
1049
|
+
if (!requestOrigin && secFetchSite && secFetchSite !== "same-origin" && secFetchSite !== "none") {
|
|
1050
|
+
return import_server3.NextResponse.json(
|
|
1051
|
+
{ error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" },
|
|
1052
|
+
{ status: 403 }
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
870
1055
|
let formGlobal = null;
|
|
871
1056
|
try {
|
|
872
1057
|
formGlobal = new URLSearchParams(await request.text()).get("global");
|
|
@@ -905,6 +1090,21 @@ function createLogoutRouteHandler(config) {
|
|
|
905
1090
|
res.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
906
1091
|
res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/", secureCookies));
|
|
907
1092
|
res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, callbackPath, secureCookies));
|
|
1093
|
+
const suffixedPrefixes = [
|
|
1094
|
+
`${stateCookieName}_`,
|
|
1095
|
+
`${nonceCookieName}_`,
|
|
1096
|
+
`${returnUrlCookieName}_`,
|
|
1097
|
+
`${verifierCookieName}_`
|
|
1098
|
+
];
|
|
1099
|
+
for (const cookie of request.cookies.getAll()) {
|
|
1100
|
+
if (!suffixedPrefixes.some((prefix) => cookie.name.startsWith(prefix))) continue;
|
|
1101
|
+
if (cookie.name.startsWith(`${verifierCookieName}_`)) {
|
|
1102
|
+
res.cookies.set(cookie.name, "", getSecureCookieOptions(0, "/", secureCookies));
|
|
1103
|
+
res.cookies.set(cookie.name, "", getSecureCookieOptions(0, callbackPath, secureCookies));
|
|
1104
|
+
} else {
|
|
1105
|
+
res.cookies.set(cookie.name, "", getHostCookieOptions(0, secureCookies));
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
908
1108
|
return res;
|
|
909
1109
|
};
|
|
910
1110
|
if (effectiveScope === "global") {
|
|
@@ -938,6 +1138,9 @@ var import_server4 = require("next/server");
|
|
|
938
1138
|
var BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
|
|
939
1139
|
var JTI_CACHE_CAPACITY = 1e3;
|
|
940
1140
|
var seenJti = /* @__PURE__ */ new Map();
|
|
1141
|
+
function releaseLogoutTokenJti(jti) {
|
|
1142
|
+
seenJti.delete(jti);
|
|
1143
|
+
}
|
|
941
1144
|
function recordJti(jti, expiresAtMs) {
|
|
942
1145
|
const now = Date.now();
|
|
943
1146
|
const existing = seenJti.get(jti);
|
|
@@ -953,7 +1156,7 @@ function recordJti(jti, expiresAtMs) {
|
|
|
953
1156
|
seenJti.set(jti, expiresAtMs);
|
|
954
1157
|
return true;
|
|
955
1158
|
}
|
|
956
|
-
async function
|
|
1159
|
+
async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId) {
|
|
957
1160
|
const baseUrl = ssoBaseUrl.replace(/\/+$/, "");
|
|
958
1161
|
const header = decodeJwtHeader(logoutToken);
|
|
959
1162
|
if (!header) {
|
|
@@ -1030,8 +1233,21 @@ async function verifyLogoutToken(logoutToken, ssoBaseUrl, clientId) {
|
|
|
1030
1233
|
if (Date.now() >= payload.exp * 1e3 + 6e4) {
|
|
1031
1234
|
throw new SsoError("logout_token_expired", "Logout Token \u5DF2\u8FC7\u671F");
|
|
1032
1235
|
}
|
|
1236
|
+
if (payload.type !== "logout_token") {
|
|
1237
|
+
throw new SsoError(
|
|
1238
|
+
"logout_token_invalid",
|
|
1239
|
+
"Logout Token type \u5FC5\u987B\u4E3A logout_token"
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1033
1242
|
const events = payload.events;
|
|
1034
|
-
if (!events || typeof events !== "object" ||
|
|
1243
|
+
if (!events || typeof events !== "object" || Array.isArray(events)) {
|
|
1244
|
+
throw new SsoError(
|
|
1245
|
+
"logout_token_invalid",
|
|
1246
|
+
"Logout Token \u7F3A\u5C11 backchannel-logout events \u58F0\u660E"
|
|
1247
|
+
);
|
|
1248
|
+
}
|
|
1249
|
+
const logoutEvent = events[BACKCHANNEL_LOGOUT_EVENT];
|
|
1250
|
+
if (!logoutEvent || typeof logoutEvent !== "object" || Array.isArray(logoutEvent)) {
|
|
1035
1251
|
throw new SsoError(
|
|
1036
1252
|
"logout_token_invalid",
|
|
1037
1253
|
"Logout Token \u7F3A\u5C11 backchannel-logout events \u58F0\u660E"
|
|
@@ -1052,7 +1268,7 @@ async function verifyLogoutToken(logoutToken, ssoBaseUrl, clientId) {
|
|
|
1052
1268
|
if (!recordJti(jti, payload.exp * 1e3 + 6e4)) {
|
|
1053
1269
|
throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
|
|
1054
1270
|
}
|
|
1055
|
-
return { sub, sid };
|
|
1271
|
+
return { payload: { sub, sid }, jti };
|
|
1056
1272
|
}
|
|
1057
1273
|
|
|
1058
1274
|
// src/next/backchannel-logout.ts
|
|
@@ -1092,9 +1308,9 @@ function createBackchannelLogoutRouteHandler(config) {
|
|
|
1092
1308
|
{ status: 400 }
|
|
1093
1309
|
);
|
|
1094
1310
|
}
|
|
1095
|
-
let
|
|
1311
|
+
let verified;
|
|
1096
1312
|
try {
|
|
1097
|
-
|
|
1313
|
+
verified = await verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId);
|
|
1098
1314
|
} catch (err) {
|
|
1099
1315
|
const code = err instanceof SsoError ? err.code : "logout_token_invalid";
|
|
1100
1316
|
const description = err instanceof SsoError ? err.description : "Logout Token \u9A8C\u8BC1\u5931\u8D25";
|
|
@@ -1104,8 +1320,9 @@ function createBackchannelLogoutRouteHandler(config) {
|
|
|
1104
1320
|
);
|
|
1105
1321
|
}
|
|
1106
1322
|
try {
|
|
1107
|
-
await onLogout?.(payload, request);
|
|
1323
|
+
await onLogout?.(verified.payload, request);
|
|
1108
1324
|
} catch (err) {
|
|
1325
|
+
releaseLogoutTokenJti(verified.jti);
|
|
1109
1326
|
console.error(
|
|
1110
1327
|
"[SSO SDK] backchannel logout onLogout \u94A9\u5B50\u6267\u884C\u5931\u8D25:",
|
|
1111
1328
|
err
|