@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.mjs
CHANGED
|
@@ -82,6 +82,7 @@ function matchesPath(pathname, paths) {
|
|
|
82
82
|
var introspectionCache = /* @__PURE__ */ new Map();
|
|
83
83
|
var INTROSPECT_CACHE_TTL_MS = 3e4;
|
|
84
84
|
var INTROSPECT_CACHE_MAX_ENTRIES = 500;
|
|
85
|
+
var INTROSPECT_TIMEOUT_MS = 5e3;
|
|
85
86
|
async function introspectCacheKey(token, clientId) {
|
|
86
87
|
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
|
|
87
88
|
const hex = Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
@@ -114,7 +115,7 @@ async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret)
|
|
|
114
115
|
const cacheKey = await introspectCacheKey(token, clientId);
|
|
115
116
|
const cached = introspectCacheGet(cacheKey);
|
|
116
117
|
if (cached) {
|
|
117
|
-
return cached.active;
|
|
118
|
+
return cached.active ? "active" : "inactive";
|
|
118
119
|
}
|
|
119
120
|
try {
|
|
120
121
|
const body = new URLSearchParams({
|
|
@@ -128,21 +129,22 @@ async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret)
|
|
|
128
129
|
const res = await fetch(`${ssoBaseUrl}/api/oauth/introspect`, {
|
|
129
130
|
method: "POST",
|
|
130
131
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
131
|
-
body: body.toString()
|
|
132
|
+
body: body.toString(),
|
|
133
|
+
signal: AbortSignal.timeout(INTROSPECT_TIMEOUT_MS)
|
|
132
134
|
});
|
|
133
135
|
if (!res.ok) {
|
|
134
136
|
if (res.status !== 401 && res.status !== 403) {
|
|
135
|
-
return
|
|
137
|
+
return "unreachable";
|
|
136
138
|
}
|
|
137
139
|
introspectCacheSet(cacheKey, false);
|
|
138
|
-
return
|
|
140
|
+
return "inactive";
|
|
139
141
|
}
|
|
140
142
|
const data = await res.json();
|
|
141
143
|
const active = data.active === true;
|
|
142
144
|
introspectCacheSet(cacheKey, active);
|
|
143
|
-
return active;
|
|
145
|
+
return active ? "active" : "inactive";
|
|
144
146
|
} catch {
|
|
145
|
-
return
|
|
147
|
+
return "unreachable";
|
|
146
148
|
}
|
|
147
149
|
}
|
|
148
150
|
function createSsoMiddleware(config) {
|
|
@@ -154,8 +156,7 @@ function createSsoMiddleware(config) {
|
|
|
154
156
|
scopes = "openid profile",
|
|
155
157
|
publicPaths = [],
|
|
156
158
|
callbackPath = "/api/auth/callback",
|
|
157
|
-
|
|
158
|
-
validateSsoCookie = true,
|
|
159
|
+
failClosedOnIntrospectionError = false,
|
|
159
160
|
insecureLocalDev: insecureLocalDevOpt = false
|
|
160
161
|
} = config;
|
|
161
162
|
const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
|
|
@@ -167,9 +168,9 @@ function createSsoMiddleware(config) {
|
|
|
167
168
|
const verifierCookieName = insecureLocalDev ? toInsecureCookieName(config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME) : config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME;
|
|
168
169
|
const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
|
|
169
170
|
const normalizedServerBase = (config.serverBaseUrl ?? ssoBaseUrl).replace(/\/+$/, "");
|
|
170
|
-
if (
|
|
171
|
+
if (config.validateSsoCookie !== void 0 || config.ssoCookieName !== void 0) {
|
|
171
172
|
console.warn(
|
|
172
|
-
|
|
173
|
+
'[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'
|
|
173
174
|
);
|
|
174
175
|
}
|
|
175
176
|
if (!clientSecret) {
|
|
@@ -189,31 +190,30 @@ function createSsoMiddleware(config) {
|
|
|
189
190
|
if (matchesPath(pathname, allPublicPaths)) {
|
|
190
191
|
return NextResponse.next();
|
|
191
192
|
}
|
|
192
|
-
const ssoSession = request.cookies.get(ssoCookieName);
|
|
193
|
-
if (ssoSession?.value) {
|
|
194
|
-
if (validateSsoCookie) {
|
|
195
|
-
const tokenActive = await introspectAccessToken(
|
|
196
|
-
ssoSession.value,
|
|
197
|
-
normalizedServerBase,
|
|
198
|
-
clientId,
|
|
199
|
-
clientSecret
|
|
200
|
-
);
|
|
201
|
-
if (tokenActive) {
|
|
202
|
-
return NextResponse.next();
|
|
203
|
-
}
|
|
204
|
-
} else {
|
|
205
|
-
return NextResponse.next();
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
193
|
const accessTokenCookie = request.cookies.get(accessTokenCookieName);
|
|
209
194
|
if (accessTokenCookie?.value) {
|
|
210
|
-
const
|
|
195
|
+
const introspectResult = await introspectAccessToken(
|
|
211
196
|
accessTokenCookie.value,
|
|
212
197
|
normalizedServerBase,
|
|
213
198
|
clientId,
|
|
214
199
|
clientSecret
|
|
215
200
|
);
|
|
216
|
-
if (
|
|
201
|
+
if (introspectResult === "active") {
|
|
202
|
+
return NextResponse.next();
|
|
203
|
+
}
|
|
204
|
+
if (introspectResult === "unreachable") {
|
|
205
|
+
if (failClosedOnIntrospectionError) {
|
|
206
|
+
console.warn(
|
|
207
|
+
"[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"
|
|
208
|
+
);
|
|
209
|
+
return new NextResponse(
|
|
210
|
+
"SSO \u8BA4\u8BC1\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF08introspection unreachable\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
|
|
211
|
+
{ status: 502 }
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
console.warn(
|
|
215
|
+
"[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"
|
|
216
|
+
);
|
|
217
217
|
return NextResponse.next();
|
|
218
218
|
}
|
|
219
219
|
}
|
|
@@ -233,11 +233,28 @@ function createSsoMiddleware(config) {
|
|
|
233
233
|
const loginUrl = new URL("/api/oauth/authorize", normalizedBase);
|
|
234
234
|
loginUrl.search = authorizeParams.toString();
|
|
235
235
|
const response = NextResponse.redirect(loginUrl);
|
|
236
|
-
|
|
237
|
-
response.cookies.set(
|
|
238
|
-
|
|
236
|
+
const attemptSuffix = `_${state}`;
|
|
237
|
+
response.cookies.set(
|
|
238
|
+
`${stateCookieName}${attemptSuffix}`,
|
|
239
|
+
state,
|
|
240
|
+
getHostCookieOptions(600, secureCookies)
|
|
241
|
+
);
|
|
242
|
+
response.cookies.set(
|
|
243
|
+
`${nonceCookieName}${attemptSuffix}`,
|
|
244
|
+
nonce,
|
|
245
|
+
getHostCookieOptions(600, secureCookies)
|
|
246
|
+
);
|
|
247
|
+
response.cookies.set(
|
|
248
|
+
`${verifierCookieName}${attemptSuffix}`,
|
|
249
|
+
verifier,
|
|
250
|
+
getSecureCookieOptions(600, callbackPath, secureCookies)
|
|
251
|
+
);
|
|
239
252
|
const safeReturnUrl = (request.nextUrl.pathname + request.nextUrl.search).slice(0, 2048);
|
|
240
|
-
response.cookies.set(
|
|
253
|
+
response.cookies.set(
|
|
254
|
+
`${returnUrlCookieName}${attemptSuffix}`,
|
|
255
|
+
safeReturnUrl,
|
|
256
|
+
getHostCookieOptions(600, secureCookies)
|
|
257
|
+
);
|
|
241
258
|
if (accessTokenCookie?.value) {
|
|
242
259
|
response.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
243
260
|
}
|
|
@@ -260,12 +277,17 @@ var SsoError = class extends Error {
|
|
|
260
277
|
};
|
|
261
278
|
|
|
262
279
|
// src/core/security.ts
|
|
280
|
+
var UNSAFE_URL_CHAR_PATTERN = /[\\\u0000-\u001F\u007F]/;
|
|
263
281
|
function isTrustedReturnUrl(url, currentOrigin) {
|
|
264
282
|
if (!url) return false;
|
|
265
|
-
if (
|
|
266
|
-
if (url.startsWith("
|
|
283
|
+
if (UNSAFE_URL_CHAR_PATTERN.test(url)) return false;
|
|
284
|
+
if (url.startsWith("//")) return false;
|
|
285
|
+
if (url.startsWith("/")) return true;
|
|
267
286
|
try {
|
|
268
|
-
|
|
287
|
+
const parsed = new URL(url);
|
|
288
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
|
|
289
|
+
if (parsed.username || parsed.password) return false;
|
|
290
|
+
return parsed.origin === currentOrigin;
|
|
269
291
|
} catch {
|
|
270
292
|
return false;
|
|
271
293
|
}
|
|
@@ -499,6 +521,92 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
|
|
|
499
521
|
}
|
|
500
522
|
|
|
501
523
|
// src/next/callback.ts
|
|
524
|
+
function escapeHtml(value) {
|
|
525
|
+
return value.replace(/[&<>"']/g, (ch) => {
|
|
526
|
+
switch (ch) {
|
|
527
|
+
case "&":
|
|
528
|
+
return "&";
|
|
529
|
+
case "<":
|
|
530
|
+
return "<";
|
|
531
|
+
case ">":
|
|
532
|
+
return ">";
|
|
533
|
+
case '"':
|
|
534
|
+
return """;
|
|
535
|
+
default:
|
|
536
|
+
return "'";
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
function buildErrorPage(status, error, errorDescription) {
|
|
541
|
+
const safeDescription = escapeHtml(errorDescription);
|
|
542
|
+
const safeError = escapeHtml(error);
|
|
543
|
+
return `<!doctype html>
|
|
544
|
+
<html lang="zh-CN">
|
|
545
|
+
<head>
|
|
546
|
+
<meta charset="utf-8" />
|
|
547
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
548
|
+
<meta name="robots" content="noindex" />
|
|
549
|
+
<title>\u767B\u5F55\u5931\u8D25</title>
|
|
550
|
+
<style>
|
|
551
|
+
:root { color-scheme: light; }
|
|
552
|
+
body { margin: 0; font-family: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; background: #fafafa; color: #2c2c2c; }
|
|
553
|
+
main { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem; text-align: center; }
|
|
554
|
+
h1 { font-size: 1.25rem; font-weight: 500; letter-spacing: 0.08em; margin: 0 0 0.75rem; }
|
|
555
|
+
p { margin: 0 0 1.5rem; color: #6b7280; font-size: 0.875rem; line-height: 1.6; max-width: 28rem; }
|
|
556
|
+
.actions { display: flex; gap: 0.75rem; }
|
|
557
|
+
a { display: inline-block; padding: 0.6rem 1.4rem; font-size: 0.8125rem; letter-spacing: 0.08em; text-decoration: none; }
|
|
558
|
+
a.primary { background: #2c2c2c; color: #fff; }
|
|
559
|
+
a.secondary { border: 1px solid rgba(44, 44, 44, 0.25); color: #2c2c2c; }
|
|
560
|
+
.code { margin-top: 2rem; font-size: 0.6875rem; color: #9ca3af; }
|
|
561
|
+
</style>
|
|
562
|
+
</head>
|
|
563
|
+
<body>
|
|
564
|
+
<main>
|
|
565
|
+
<h1>\u767B\u5F55\u5931\u8D25</h1>
|
|
566
|
+
<p>${safeDescription}</p>
|
|
567
|
+
<div class="actions">
|
|
568
|
+
<a class="primary" href="/">\u91CD\u65B0\u767B\u5F55</a>
|
|
569
|
+
<a class="secondary" href="/">\u8FD4\u56DE\u9996\u9875</a>
|
|
570
|
+
</div>
|
|
571
|
+
<p class="code">\u9519\u8BEF\u7801\uFF1A${safeError}\uFF08\u53CD\u9988\u95EE\u9898\u65F6\u8BF7\u9644\u4E0A\uFF09</p>
|
|
572
|
+
</main>
|
|
573
|
+
</body>
|
|
574
|
+
</html>`;
|
|
575
|
+
}
|
|
576
|
+
function buildErrorResponse(request, status, error, errorDescription, custom) {
|
|
577
|
+
if (custom) {
|
|
578
|
+
const handled = custom({ error, errorDescription, status, request });
|
|
579
|
+
if (handled instanceof Promise) {
|
|
580
|
+
return handled.then(
|
|
581
|
+
(res) => res ? toNextResponse(res) : defaultErrorResponse(request, status, error, errorDescription)
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
if (handled) return toNextResponse(handled);
|
|
585
|
+
}
|
|
586
|
+
return defaultErrorResponse(request, status, error, errorDescription);
|
|
587
|
+
}
|
|
588
|
+
function toNextResponse(res) {
|
|
589
|
+
if (res instanceof NextResponse2) return res;
|
|
590
|
+
return new NextResponse2(res.body, {
|
|
591
|
+
status: res.status,
|
|
592
|
+
statusText: res.statusText,
|
|
593
|
+
headers: res.headers
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
function defaultErrorResponse(request, status, error, errorDescription) {
|
|
597
|
+
const accept = request.headers.get("accept") ?? "";
|
|
598
|
+
const wantsHtml = accept.includes("text/html") && request.nextUrl.searchParams.get("format") !== "json";
|
|
599
|
+
if (wantsHtml) {
|
|
600
|
+
return new NextResponse2(buildErrorPage(status, error, errorDescription), {
|
|
601
|
+
status,
|
|
602
|
+
headers: {
|
|
603
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
604
|
+
"Cache-Control": "no-store"
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
return NextResponse2.json({ error, error_description: errorDescription }, { status });
|
|
609
|
+
}
|
|
502
610
|
function createCallbackRouteHandler(config) {
|
|
503
611
|
const {
|
|
504
612
|
clientId,
|
|
@@ -506,6 +614,7 @@ function createCallbackRouteHandler(config) {
|
|
|
506
614
|
redirectUri,
|
|
507
615
|
clientSecret,
|
|
508
616
|
defaultReturnPath = "/",
|
|
617
|
+
scopes,
|
|
509
618
|
insecureLocalDev: insecureLocalDevOpt = false
|
|
510
619
|
} = config;
|
|
511
620
|
const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
|
|
@@ -523,7 +632,21 @@ function createCallbackRouteHandler(config) {
|
|
|
523
632
|
return async function GET(request) {
|
|
524
633
|
const response = await handleCallback(request);
|
|
525
634
|
if (response.status >= 400) {
|
|
635
|
+
if (config.debug) {
|
|
636
|
+
console.warn(
|
|
637
|
+
`[SSO SDK] \u56DE\u8C03\u5931\u8D25 status=${response.status}`,
|
|
638
|
+
request.nextUrl.searchParams.get("error") ?? ""
|
|
639
|
+
);
|
|
640
|
+
}
|
|
526
641
|
response.cookies.set(nonceCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
642
|
+
const returnedState = request.nextUrl.searchParams.get("state");
|
|
643
|
+
if (returnedState && /^[A-Za-z0-9\-._~]{8,512}$/.test(returnedState)) {
|
|
644
|
+
response.cookies.set(
|
|
645
|
+
`${nonceCookieName}_${returnedState}`,
|
|
646
|
+
"",
|
|
647
|
+
getHostCookieOptions(0, secureCookies)
|
|
648
|
+
);
|
|
649
|
+
}
|
|
527
650
|
}
|
|
528
651
|
return response;
|
|
529
652
|
};
|
|
@@ -532,50 +655,75 @@ function createCallbackRouteHandler(config) {
|
|
|
532
655
|
const error = searchParams.get("error");
|
|
533
656
|
if (error) {
|
|
534
657
|
const desc = searchParams.get("error_description") || error;
|
|
535
|
-
return
|
|
536
|
-
|
|
537
|
-
|
|
658
|
+
return buildErrorResponse(
|
|
659
|
+
request,
|
|
660
|
+
400,
|
|
661
|
+
"authorization_failed",
|
|
662
|
+
desc,
|
|
663
|
+
config.renderErrorPage
|
|
538
664
|
);
|
|
539
665
|
}
|
|
540
666
|
const code = searchParams.get("code");
|
|
541
667
|
const returnedState = searchParams.get("state");
|
|
542
668
|
if (!code) {
|
|
543
|
-
return
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
669
|
+
return buildErrorResponse(
|
|
670
|
+
request,
|
|
671
|
+
400,
|
|
672
|
+
"invalid_request",
|
|
673
|
+
"\u767B\u5F55\u4FE1\u606F\u4E0D\u5B8C\u6574\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u767B\u5F55",
|
|
674
|
+
config.renderErrorPage
|
|
549
675
|
);
|
|
550
676
|
}
|
|
551
|
-
|
|
677
|
+
if (!returnedState || !/^[A-Za-z0-9\-._~]{8,512}$/.test(returnedState)) {
|
|
678
|
+
return buildErrorResponse(
|
|
679
|
+
request,
|
|
680
|
+
400,
|
|
681
|
+
"invalid_request",
|
|
682
|
+
"State \u53C2\u6570\u7F3A\u5931\u6216\u683C\u5F0F\u975E\u6CD5\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42",
|
|
683
|
+
config.renderErrorPage
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
const attemptSuffix = `_${returnedState}`;
|
|
687
|
+
const readTransientCookie = (name) => request.cookies.get(`${name}${attemptSuffix}`)?.value ?? request.cookies.get(name)?.value;
|
|
688
|
+
const savedState = readTransientCookie(stateCookieName);
|
|
552
689
|
if (!savedState) {
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
690
|
+
const alternateName = secureCookies ? toInsecureCookieName(stateCookieName) : DEFAULT_STATE_COOKIE_NAME;
|
|
691
|
+
const alternateValue = request.cookies.get(`${alternateName}${attemptSuffix}`)?.value ?? request.cookies.get(alternateName)?.value;
|
|
692
|
+
if (alternateValue) {
|
|
693
|
+
return buildErrorResponse(
|
|
694
|
+
request,
|
|
695
|
+
500,
|
|
696
|
+
"invalid_config",
|
|
697
|
+
"\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",
|
|
698
|
+
config.renderErrorPage
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
return buildErrorResponse(
|
|
702
|
+
request,
|
|
703
|
+
400,
|
|
704
|
+
"invalid_request",
|
|
705
|
+
"\u767B\u5F55\u4F1A\u8BDD\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42",
|
|
706
|
+
config.renderErrorPage
|
|
559
707
|
);
|
|
560
708
|
}
|
|
561
|
-
if (!timingSafeEqualString(returnedState
|
|
562
|
-
return
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
709
|
+
if (!timingSafeEqualString(returnedState, savedState)) {
|
|
710
|
+
return buildErrorResponse(
|
|
711
|
+
request,
|
|
712
|
+
400,
|
|
713
|
+
"invalid_request",
|
|
714
|
+
"\u767B\u5F55\u4F1A\u8BDD\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42",
|
|
715
|
+
config.renderErrorPage
|
|
568
716
|
);
|
|
569
717
|
}
|
|
570
|
-
const expectedNonce =
|
|
571
|
-
const verifier =
|
|
718
|
+
const expectedNonce = readTransientCookie(nonceCookieName);
|
|
719
|
+
const verifier = readTransientCookie(verifierCookieName);
|
|
572
720
|
if (!verifier) {
|
|
573
|
-
return
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
721
|
+
return buildErrorResponse(
|
|
722
|
+
request,
|
|
723
|
+
400,
|
|
724
|
+
"invalid_request",
|
|
725
|
+
"\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",
|
|
726
|
+
config.renderErrorPage
|
|
579
727
|
);
|
|
580
728
|
}
|
|
581
729
|
const tokenEndpoint = `${normalizedServerBase}/api/oauth/token`;
|
|
@@ -606,17 +754,23 @@ function createCallbackRouteHandler(config) {
|
|
|
606
754
|
} catch (err) {
|
|
607
755
|
lastError = err;
|
|
608
756
|
if (attempt >= maxRetries) {
|
|
609
|
-
return
|
|
610
|
-
|
|
611
|
-
|
|
757
|
+
return buildErrorResponse(
|
|
758
|
+
request,
|
|
759
|
+
502,
|
|
760
|
+
"server_error",
|
|
761
|
+
"\u767B\u5F55\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF08Token \u8BF7\u6C42\u5931\u8D25\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
|
|
762
|
+
config.renderErrorPage
|
|
612
763
|
);
|
|
613
764
|
}
|
|
614
765
|
}
|
|
615
766
|
}
|
|
616
767
|
if (lastError || !res) {
|
|
617
|
-
return
|
|
618
|
-
|
|
619
|
-
|
|
768
|
+
return buildErrorResponse(
|
|
769
|
+
request,
|
|
770
|
+
502,
|
|
771
|
+
"server_error",
|
|
772
|
+
"\u767B\u5F55\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF08Token \u8BF7\u6C42\u5931\u8D25\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
|
|
773
|
+
config.renderErrorPage
|
|
620
774
|
);
|
|
621
775
|
}
|
|
622
776
|
if (!res.ok) {
|
|
@@ -625,22 +779,32 @@ function createCallbackRouteHandler(config) {
|
|
|
625
779
|
errData = await res.json();
|
|
626
780
|
} catch {
|
|
627
781
|
}
|
|
628
|
-
return
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
}
|
|
633
|
-
|
|
782
|
+
return buildErrorResponse(
|
|
783
|
+
request,
|
|
784
|
+
502,
|
|
785
|
+
"token_request_failed",
|
|
786
|
+
errData.error_description || `\u767B\u5F55\u5931\u8D25\uFF08Token \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5`,
|
|
787
|
+
config.renderErrorPage
|
|
634
788
|
);
|
|
635
789
|
}
|
|
636
790
|
const tokenData = await res.json();
|
|
637
|
-
if (!tokenData.access_token || !tokenData.refresh_token) {
|
|
638
|
-
return
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
791
|
+
if (!tokenData.access_token || !tokenData.refresh_token || typeof tokenData.expires_in !== "number" || !Number.isFinite(tokenData.expires_in) || tokenData.expires_in <= 0) {
|
|
792
|
+
return buildErrorResponse(
|
|
793
|
+
request,
|
|
794
|
+
502,
|
|
795
|
+
"server_error",
|
|
796
|
+
"\u767B\u5F55\u670D\u52A1\u8FD4\u56DE\u5F02\u5E38\uFF08Token \u54CD\u5E94\u4E0D\u5B8C\u6574\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
|
|
797
|
+
config.renderErrorPage
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
const requiresIdToken = scopes !== void 0 && scopes.split(" ").filter(Boolean).includes("openid");
|
|
801
|
+
if (requiresIdToken && !tokenData.id_token) {
|
|
802
|
+
return buildErrorResponse(
|
|
803
|
+
request,
|
|
804
|
+
400,
|
|
805
|
+
"id_token_invalid",
|
|
806
|
+
"\u767B\u5F55\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u767B\u5F55",
|
|
807
|
+
config.renderErrorPage
|
|
644
808
|
);
|
|
645
809
|
}
|
|
646
810
|
if (tokenData.id_token) {
|
|
@@ -653,17 +817,17 @@ function createCallbackRouteHandler(config) {
|
|
|
653
817
|
{ expectedNonce }
|
|
654
818
|
);
|
|
655
819
|
} catch (err) {
|
|
656
|
-
return
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
820
|
+
return buildErrorResponse(
|
|
821
|
+
request,
|
|
822
|
+
400,
|
|
823
|
+
"id_token_invalid",
|
|
824
|
+
err instanceof Error && /[\u4e00-\u9fff]/.test(err.message) ? err.message : "\u767B\u5F55\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u767B\u5F55",
|
|
825
|
+
config.renderErrorPage
|
|
662
826
|
);
|
|
663
827
|
}
|
|
664
828
|
}
|
|
665
829
|
const callbackOrigin = new URL(redirectUri).origin;
|
|
666
|
-
const rawReturnUrl =
|
|
830
|
+
const rawReturnUrl = readTransientCookie(returnUrlCookieName) || defaultReturnPath;
|
|
667
831
|
const returnUrl = isTrustedReturnUrl(rawReturnUrl, callbackOrigin) ? rawReturnUrl : "/";
|
|
668
832
|
const response = NextResponse2.redirect(new URL(returnUrl, callbackOrigin));
|
|
669
833
|
response.cookies.set(accessTokenCookieName, tokenData.access_token, {
|
|
@@ -693,11 +857,18 @@ function createCallbackRouteHandler(config) {
|
|
|
693
857
|
});
|
|
694
858
|
}
|
|
695
859
|
}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
860
|
+
for (const name of [stateCookieName, nonceCookieName, returnUrlCookieName]) {
|
|
861
|
+
response.cookies.set(name, "", getHostCookieOptions(0, secureCookies));
|
|
862
|
+
response.cookies.set(`${name}${attemptSuffix}`, "", getHostCookieOptions(0, secureCookies));
|
|
863
|
+
}
|
|
864
|
+
for (const path of ["/", request.nextUrl.pathname]) {
|
|
865
|
+
response.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, path, secureCookies));
|
|
866
|
+
response.cookies.set(
|
|
867
|
+
`${verifierCookieName}${attemptSuffix}`,
|
|
868
|
+
"",
|
|
869
|
+
getSecureCookieOptions(0, path, secureCookies)
|
|
870
|
+
);
|
|
871
|
+
}
|
|
701
872
|
return response;
|
|
702
873
|
}
|
|
703
874
|
}
|
|
@@ -827,6 +998,20 @@ function createLogoutRouteHandler(config) {
|
|
|
827
998
|
headers: { "content-type": "text/html; charset=utf-8" }
|
|
828
999
|
});
|
|
829
1000
|
}
|
|
1001
|
+
const requestOrigin = request.headers.get("origin");
|
|
1002
|
+
if (requestOrigin && requestOrigin !== callbackOrigin) {
|
|
1003
|
+
return NextResponse3.json(
|
|
1004
|
+
{ error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" },
|
|
1005
|
+
{ status: 403 }
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
const secFetchSite = request.headers.get("sec-fetch-site");
|
|
1009
|
+
if (!requestOrigin && secFetchSite && secFetchSite !== "same-origin" && secFetchSite !== "none") {
|
|
1010
|
+
return NextResponse3.json(
|
|
1011
|
+
{ error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" },
|
|
1012
|
+
{ status: 403 }
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
830
1015
|
let formGlobal = null;
|
|
831
1016
|
try {
|
|
832
1017
|
formGlobal = new URLSearchParams(await request.text()).get("global");
|
|
@@ -865,6 +1050,21 @@ function createLogoutRouteHandler(config) {
|
|
|
865
1050
|
res.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
866
1051
|
res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/", secureCookies));
|
|
867
1052
|
res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, callbackPath, secureCookies));
|
|
1053
|
+
const suffixedPrefixes = [
|
|
1054
|
+
`${stateCookieName}_`,
|
|
1055
|
+
`${nonceCookieName}_`,
|
|
1056
|
+
`${returnUrlCookieName}_`,
|
|
1057
|
+
`${verifierCookieName}_`
|
|
1058
|
+
];
|
|
1059
|
+
for (const cookie of request.cookies.getAll()) {
|
|
1060
|
+
if (!suffixedPrefixes.some((prefix) => cookie.name.startsWith(prefix))) continue;
|
|
1061
|
+
if (cookie.name.startsWith(`${verifierCookieName}_`)) {
|
|
1062
|
+
res.cookies.set(cookie.name, "", getSecureCookieOptions(0, "/", secureCookies));
|
|
1063
|
+
res.cookies.set(cookie.name, "", getSecureCookieOptions(0, callbackPath, secureCookies));
|
|
1064
|
+
} else {
|
|
1065
|
+
res.cookies.set(cookie.name, "", getHostCookieOptions(0, secureCookies));
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
868
1068
|
return res;
|
|
869
1069
|
};
|
|
870
1070
|
if (effectiveScope === "global") {
|
|
@@ -898,6 +1098,9 @@ import { NextResponse as NextResponse4 } from "next/server";
|
|
|
898
1098
|
var BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
|
|
899
1099
|
var JTI_CACHE_CAPACITY = 1e3;
|
|
900
1100
|
var seenJti = /* @__PURE__ */ new Map();
|
|
1101
|
+
function releaseLogoutTokenJti(jti) {
|
|
1102
|
+
seenJti.delete(jti);
|
|
1103
|
+
}
|
|
901
1104
|
function recordJti(jti, expiresAtMs) {
|
|
902
1105
|
const now = Date.now();
|
|
903
1106
|
const existing = seenJti.get(jti);
|
|
@@ -913,7 +1116,7 @@ function recordJti(jti, expiresAtMs) {
|
|
|
913
1116
|
seenJti.set(jti, expiresAtMs);
|
|
914
1117
|
return true;
|
|
915
1118
|
}
|
|
916
|
-
async function
|
|
1119
|
+
async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId) {
|
|
917
1120
|
const baseUrl = ssoBaseUrl.replace(/\/+$/, "");
|
|
918
1121
|
const header = decodeJwtHeader(logoutToken);
|
|
919
1122
|
if (!header) {
|
|
@@ -990,8 +1193,21 @@ async function verifyLogoutToken(logoutToken, ssoBaseUrl, clientId) {
|
|
|
990
1193
|
if (Date.now() >= payload.exp * 1e3 + 6e4) {
|
|
991
1194
|
throw new SsoError("logout_token_expired", "Logout Token \u5DF2\u8FC7\u671F");
|
|
992
1195
|
}
|
|
1196
|
+
if (payload.type !== "logout_token") {
|
|
1197
|
+
throw new SsoError(
|
|
1198
|
+
"logout_token_invalid",
|
|
1199
|
+
"Logout Token type \u5FC5\u987B\u4E3A logout_token"
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
993
1202
|
const events = payload.events;
|
|
994
|
-
if (!events || typeof events !== "object" ||
|
|
1203
|
+
if (!events || typeof events !== "object" || Array.isArray(events)) {
|
|
1204
|
+
throw new SsoError(
|
|
1205
|
+
"logout_token_invalid",
|
|
1206
|
+
"Logout Token \u7F3A\u5C11 backchannel-logout events \u58F0\u660E"
|
|
1207
|
+
);
|
|
1208
|
+
}
|
|
1209
|
+
const logoutEvent = events[BACKCHANNEL_LOGOUT_EVENT];
|
|
1210
|
+
if (!logoutEvent || typeof logoutEvent !== "object" || Array.isArray(logoutEvent)) {
|
|
995
1211
|
throw new SsoError(
|
|
996
1212
|
"logout_token_invalid",
|
|
997
1213
|
"Logout Token \u7F3A\u5C11 backchannel-logout events \u58F0\u660E"
|
|
@@ -1012,7 +1228,7 @@ async function verifyLogoutToken(logoutToken, ssoBaseUrl, clientId) {
|
|
|
1012
1228
|
if (!recordJti(jti, payload.exp * 1e3 + 6e4)) {
|
|
1013
1229
|
throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
|
|
1014
1230
|
}
|
|
1015
|
-
return { sub, sid };
|
|
1231
|
+
return { payload: { sub, sid }, jti };
|
|
1016
1232
|
}
|
|
1017
1233
|
|
|
1018
1234
|
// src/next/backchannel-logout.ts
|
|
@@ -1052,9 +1268,9 @@ function createBackchannelLogoutRouteHandler(config) {
|
|
|
1052
1268
|
{ status: 400 }
|
|
1053
1269
|
);
|
|
1054
1270
|
}
|
|
1055
|
-
let
|
|
1271
|
+
let verified;
|
|
1056
1272
|
try {
|
|
1057
|
-
|
|
1273
|
+
verified = await verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId);
|
|
1058
1274
|
} catch (err) {
|
|
1059
1275
|
const code = err instanceof SsoError ? err.code : "logout_token_invalid";
|
|
1060
1276
|
const description = err instanceof SsoError ? err.description : "Logout Token \u9A8C\u8BC1\u5931\u8D25";
|
|
@@ -1064,8 +1280,9 @@ function createBackchannelLogoutRouteHandler(config) {
|
|
|
1064
1280
|
);
|
|
1065
1281
|
}
|
|
1066
1282
|
try {
|
|
1067
|
-
await onLogout?.(payload, request);
|
|
1283
|
+
await onLogout?.(verified.payload, request);
|
|
1068
1284
|
} catch (err) {
|
|
1285
|
+
releaseLogoutTokenJti(verified.jti);
|
|
1069
1286
|
console.error(
|
|
1070
1287
|
"[SSO SDK] backchannel logout onLogout \u94A9\u5B50\u6267\u884C\u5931\u8D25:",
|
|
1071
1288
|
err
|