@nihplod/sso-sdk 1.4.1 → 1.6.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 +101 -9
- package/dist/index.d.mts +52 -3
- package/dist/index.d.ts +52 -3
- package/dist/index.js +246 -42
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +246 -42
- package/dist/index.mjs.map +1 -1
- package/dist/next/index.d.mts +88 -2
- package/dist/next/index.d.ts +88 -2
- package/dist/next/index.js +380 -109
- package/dist/next/index.js.map +1 -1
- package/dist/next/index.mjs +380 -109
- package/dist/next/index.mjs.map +1 -1
- package/dist/react/index.d.mts +50 -1
- package/dist/react/index.d.ts +50 -1
- package/dist/react/index.js +414 -75
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +414 -75
- package/dist/react/index.mjs.map +1 -1
- package/package.json +9 -3
package/dist/next/index.mjs
CHANGED
|
@@ -74,15 +74,18 @@ async function computeCodeChallenge(verifier) {
|
|
|
74
74
|
function matchesPath(pathname, paths) {
|
|
75
75
|
return paths.some((path) => {
|
|
76
76
|
if (pathname === path) return true;
|
|
77
|
-
if (path.endsWith("/:path*")
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
if (path.endsWith("/:path*")) {
|
|
78
|
+
const base = path.slice(0, -"/:path*".length);
|
|
79
|
+
return pathname === base || pathname.startsWith(base + "/");
|
|
80
|
+
}
|
|
81
|
+
return pathname.startsWith(path + "/");
|
|
80
82
|
});
|
|
81
83
|
}
|
|
82
84
|
var introspectionCache = /* @__PURE__ */ new Map();
|
|
83
85
|
var INTROSPECT_CACHE_TTL_MS = 3e4;
|
|
84
86
|
var INTROSPECT_CACHE_MAX_ENTRIES = 500;
|
|
85
87
|
var INTROSPECT_TIMEOUT_MS = 5e3;
|
|
88
|
+
var warnedIntrospectClientAuthFailure = false;
|
|
86
89
|
async function introspectCacheKey(token, clientId) {
|
|
87
90
|
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
|
|
88
91
|
const hex = Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
@@ -136,8 +139,13 @@ async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret)
|
|
|
136
139
|
if (res.status !== 401 && res.status !== 403) {
|
|
137
140
|
return "unreachable";
|
|
138
141
|
}
|
|
139
|
-
|
|
140
|
-
|
|
142
|
+
if (!warnedIntrospectClientAuthFailure) {
|
|
143
|
+
warnedIntrospectClientAuthFailure = true;
|
|
144
|
+
console.warn(
|
|
145
|
+
`[SSO SDK] introspection \u5BA2\u6237\u7AEF\u8BA4\u8BC1\u5931\u8D25\uFF08HTTP ${res.status}\uFF09\uFF1A\u8BF7\u68C0\u67E5 clientId/clientSecret \u4E0E\u4E3B\u7AD9\u6CE8\u518C\u4FE1\u606F\u662F\u5426\u4E00\u81F4\u3002\u8BE5\u6545\u969C\u4E0D\u4F1A\u88AB\u7F13\u5B58\u4E3A token \u5931\u6548\uFF0C\u4E5F\u4E0D\u4F1A\u89E6\u53D1\u9759\u9ED8\u91CD\u767B\u5F55\u5FAA\u73AF\u3002`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return "unreachable";
|
|
141
149
|
}
|
|
142
150
|
const data = await res.json();
|
|
143
151
|
const active = data.active === true;
|
|
@@ -156,8 +164,7 @@ function createSsoMiddleware(config) {
|
|
|
156
164
|
scopes = "openid profile",
|
|
157
165
|
publicPaths = [],
|
|
158
166
|
callbackPath = "/api/auth/callback",
|
|
159
|
-
|
|
160
|
-
validateSsoCookie = true,
|
|
167
|
+
failClosedOnIntrospectionError = false,
|
|
161
168
|
insecureLocalDev: insecureLocalDevOpt = false
|
|
162
169
|
} = config;
|
|
163
170
|
const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
|
|
@@ -169,9 +176,9 @@ function createSsoMiddleware(config) {
|
|
|
169
176
|
const verifierCookieName = insecureLocalDev ? toInsecureCookieName(config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME) : config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME;
|
|
170
177
|
const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
|
|
171
178
|
const normalizedServerBase = (config.serverBaseUrl ?? ssoBaseUrl).replace(/\/+$/, "");
|
|
172
|
-
if (
|
|
179
|
+
if (config.validateSsoCookie !== void 0 || config.ssoCookieName !== void 0) {
|
|
173
180
|
console.warn(
|
|
174
|
-
|
|
181
|
+
'[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'
|
|
175
182
|
);
|
|
176
183
|
}
|
|
177
184
|
if (!clientSecret) {
|
|
@@ -181,7 +188,7 @@ function createSsoMiddleware(config) {
|
|
|
181
188
|
}
|
|
182
189
|
return async function ssoMiddleware(request) {
|
|
183
190
|
const { pathname } = request.nextUrl;
|
|
184
|
-
if (pathname.startsWith("/_next/") || pathname.startsWith("/favicon.ico") || pathname.match(/\.(ico|png|jpg|jpeg|svg|css|js|woff2?)$/)) {
|
|
191
|
+
if (!pathname.startsWith("/api/") && (pathname.startsWith("/_next/") || pathname.startsWith("/favicon.ico") || pathname.match(/\.(ico|png|jpg|jpeg|svg|css|js|woff2?)$/))) {
|
|
185
192
|
return NextResponse.next();
|
|
186
193
|
}
|
|
187
194
|
if (pathname === callbackPath) {
|
|
@@ -191,22 +198,6 @@ function createSsoMiddleware(config) {
|
|
|
191
198
|
if (matchesPath(pathname, allPublicPaths)) {
|
|
192
199
|
return NextResponse.next();
|
|
193
200
|
}
|
|
194
|
-
const ssoSession = request.cookies.get(ssoCookieName);
|
|
195
|
-
if (ssoSession?.value) {
|
|
196
|
-
if (validateSsoCookie) {
|
|
197
|
-
const introspectResult = await introspectAccessToken(
|
|
198
|
-
ssoSession.value,
|
|
199
|
-
normalizedServerBase,
|
|
200
|
-
clientId,
|
|
201
|
-
clientSecret
|
|
202
|
-
);
|
|
203
|
-
if (introspectResult === "active") {
|
|
204
|
-
return NextResponse.next();
|
|
205
|
-
}
|
|
206
|
-
} else {
|
|
207
|
-
return NextResponse.next();
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
201
|
const accessTokenCookie = request.cookies.get(accessTokenCookieName);
|
|
211
202
|
if (accessTokenCookie?.value) {
|
|
212
203
|
const introspectResult = await introspectAccessToken(
|
|
@@ -219,6 +210,15 @@ function createSsoMiddleware(config) {
|
|
|
219
210
|
return NextResponse.next();
|
|
220
211
|
}
|
|
221
212
|
if (introspectResult === "unreachable") {
|
|
213
|
+
if (failClosedOnIntrospectionError) {
|
|
214
|
+
console.warn(
|
|
215
|
+
"[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"
|
|
216
|
+
);
|
|
217
|
+
return new NextResponse(
|
|
218
|
+
"SSO \u8BA4\u8BC1\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF08introspection unreachable\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
|
|
219
|
+
{ status: 502 }
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
222
|
console.warn(
|
|
223
223
|
"[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"
|
|
224
224
|
);
|
|
@@ -241,11 +241,28 @@ function createSsoMiddleware(config) {
|
|
|
241
241
|
const loginUrl = new URL("/api/oauth/authorize", normalizedBase);
|
|
242
242
|
loginUrl.search = authorizeParams.toString();
|
|
243
243
|
const response = NextResponse.redirect(loginUrl);
|
|
244
|
-
|
|
245
|
-
response.cookies.set(
|
|
246
|
-
|
|
244
|
+
const attemptSuffix = `_${state}`;
|
|
245
|
+
response.cookies.set(
|
|
246
|
+
`${stateCookieName}${attemptSuffix}`,
|
|
247
|
+
state,
|
|
248
|
+
getHostCookieOptions(600, secureCookies)
|
|
249
|
+
);
|
|
250
|
+
response.cookies.set(
|
|
251
|
+
`${nonceCookieName}${attemptSuffix}`,
|
|
252
|
+
nonce,
|
|
253
|
+
getHostCookieOptions(600, secureCookies)
|
|
254
|
+
);
|
|
255
|
+
response.cookies.set(
|
|
256
|
+
`${verifierCookieName}${attemptSuffix}`,
|
|
257
|
+
verifier,
|
|
258
|
+
getSecureCookieOptions(600, callbackPath, secureCookies)
|
|
259
|
+
);
|
|
247
260
|
const safeReturnUrl = (request.nextUrl.pathname + request.nextUrl.search).slice(0, 2048);
|
|
248
|
-
response.cookies.set(
|
|
261
|
+
response.cookies.set(
|
|
262
|
+
`${returnUrlCookieName}${attemptSuffix}`,
|
|
263
|
+
safeReturnUrl,
|
|
264
|
+
getHostCookieOptions(600, secureCookies)
|
|
265
|
+
);
|
|
249
266
|
if (accessTokenCookie?.value) {
|
|
250
267
|
response.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
251
268
|
}
|
|
@@ -268,12 +285,17 @@ var SsoError = class extends Error {
|
|
|
268
285
|
};
|
|
269
286
|
|
|
270
287
|
// src/core/security.ts
|
|
288
|
+
var UNSAFE_URL_CHAR_PATTERN = /[\\\u0000-\u001F\u007F]/;
|
|
271
289
|
function isTrustedReturnUrl(url, currentOrigin) {
|
|
272
290
|
if (!url) return false;
|
|
273
|
-
if (
|
|
274
|
-
if (url.startsWith("
|
|
291
|
+
if (UNSAFE_URL_CHAR_PATTERN.test(url)) return false;
|
|
292
|
+
if (url.startsWith("//")) return false;
|
|
293
|
+
if (url.startsWith("/")) return true;
|
|
275
294
|
try {
|
|
276
|
-
|
|
295
|
+
const parsed = new URL(url);
|
|
296
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
|
|
297
|
+
if (parsed.username || parsed.password) return false;
|
|
298
|
+
return parsed.origin === currentOrigin;
|
|
277
299
|
} catch {
|
|
278
300
|
return false;
|
|
279
301
|
}
|
|
@@ -485,7 +507,10 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
|
|
|
485
507
|
if (Date.now() >= payload.exp * 1e3 + 6e4) {
|
|
486
508
|
throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
|
|
487
509
|
}
|
|
488
|
-
if (typeof payload.iat
|
|
510
|
+
if (typeof payload.iat !== "number") {
|
|
511
|
+
throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 iat \u58F0\u660E");
|
|
512
|
+
}
|
|
513
|
+
if (payload.iat * 1e3 > Date.now() + 6e4) {
|
|
489
514
|
throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
|
|
490
515
|
}
|
|
491
516
|
if (typeof payload.sub !== "string" || !payload.sub) {
|
|
@@ -502,11 +527,114 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
|
|
|
502
527
|
if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
|
|
503
528
|
throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
|
|
504
529
|
}
|
|
530
|
+
} else if (typeof payload.nonce === "string" && payload.nonce) {
|
|
531
|
+
throw new SsoError(
|
|
532
|
+
"id_token_nonce_mismatch",
|
|
533
|
+
"ID Token \u643A\u5E26 nonce \u4F46\u672C\u5730\u65E0\u671F\u671B\u503C\uFF08\u53EF\u80FD\u975E\u672C\u6B21\u767B\u5F55\u4F1A\u8BDD\u7B7E\u53D1\uFF09"
|
|
534
|
+
);
|
|
505
535
|
}
|
|
506
536
|
return { sub: payload.sub };
|
|
507
537
|
}
|
|
508
538
|
|
|
539
|
+
// src/core/http.ts
|
|
540
|
+
var REQUEST_TIMEOUT_MS = 1e4;
|
|
541
|
+
async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
542
|
+
const controller = new AbortController();
|
|
543
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
544
|
+
try {
|
|
545
|
+
return await fetch(input, { ...init, signal: controller.signal });
|
|
546
|
+
} finally {
|
|
547
|
+
clearTimeout(timer);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
509
551
|
// src/next/callback.ts
|
|
552
|
+
function escapeHtml(value) {
|
|
553
|
+
return value.replace(/[&<>"']/g, (ch) => {
|
|
554
|
+
switch (ch) {
|
|
555
|
+
case "&":
|
|
556
|
+
return "&";
|
|
557
|
+
case "<":
|
|
558
|
+
return "<";
|
|
559
|
+
case ">":
|
|
560
|
+
return ">";
|
|
561
|
+
case '"':
|
|
562
|
+
return """;
|
|
563
|
+
default:
|
|
564
|
+
return "'";
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
function buildErrorPage(status, error, errorDescription) {
|
|
569
|
+
const safeDescription = escapeHtml(errorDescription);
|
|
570
|
+
const safeError = escapeHtml(error);
|
|
571
|
+
return `<!doctype html>
|
|
572
|
+
<html lang="zh-CN">
|
|
573
|
+
<head>
|
|
574
|
+
<meta charset="utf-8" />
|
|
575
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
576
|
+
<meta name="robots" content="noindex" />
|
|
577
|
+
<title>\u767B\u5F55\u5931\u8D25</title>
|
|
578
|
+
<style>
|
|
579
|
+
:root { color-scheme: light; }
|
|
580
|
+
body { margin: 0; font-family: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; background: #fafafa; color: #2c2c2c; }
|
|
581
|
+
main { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem; text-align: center; }
|
|
582
|
+
h1 { font-size: 1.25rem; font-weight: 500; letter-spacing: 0.08em; margin: 0 0 0.75rem; }
|
|
583
|
+
p { margin: 0 0 1.5rem; color: #6b7280; font-size: 0.875rem; line-height: 1.6; max-width: 28rem; }
|
|
584
|
+
.actions { display: flex; gap: 0.75rem; }
|
|
585
|
+
a { display: inline-block; padding: 0.6rem 1.4rem; font-size: 0.8125rem; letter-spacing: 0.08em; text-decoration: none; }
|
|
586
|
+
a.primary { background: #2c2c2c; color: #fff; }
|
|
587
|
+
a.secondary { border: 1px solid rgba(44, 44, 44, 0.25); color: #2c2c2c; }
|
|
588
|
+
.code { margin-top: 2rem; font-size: 0.6875rem; color: #9ca3af; }
|
|
589
|
+
</style>
|
|
590
|
+
</head>
|
|
591
|
+
<body>
|
|
592
|
+
<main>
|
|
593
|
+
<h1>\u767B\u5F55\u5931\u8D25</h1>
|
|
594
|
+
<p>${safeDescription}</p>
|
|
595
|
+
<div class="actions">
|
|
596
|
+
<a class="primary" href="/">\u91CD\u65B0\u767B\u5F55</a>
|
|
597
|
+
<a class="secondary" href="/">\u8FD4\u56DE\u9996\u9875</a>
|
|
598
|
+
</div>
|
|
599
|
+
<p class="code">\u9519\u8BEF\u7801\uFF1A${safeError}\uFF08\u53CD\u9988\u95EE\u9898\u65F6\u8BF7\u9644\u4E0A\uFF09</p>
|
|
600
|
+
</main>
|
|
601
|
+
</body>
|
|
602
|
+
</html>`;
|
|
603
|
+
}
|
|
604
|
+
function buildErrorResponse(request, status, error, errorDescription, custom) {
|
|
605
|
+
if (custom) {
|
|
606
|
+
const handled = custom({ error, errorDescription, status, request });
|
|
607
|
+
if (handled instanceof Promise) {
|
|
608
|
+
return handled.then(
|
|
609
|
+
(res) => res ? toNextResponse(res) : defaultErrorResponse(request, status, error, errorDescription)
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
if (handled) return toNextResponse(handled);
|
|
613
|
+
}
|
|
614
|
+
return defaultErrorResponse(request, status, error, errorDescription);
|
|
615
|
+
}
|
|
616
|
+
function toNextResponse(res) {
|
|
617
|
+
if (res instanceof NextResponse2) return res;
|
|
618
|
+
return new NextResponse2(res.body, {
|
|
619
|
+
status: res.status,
|
|
620
|
+
statusText: res.statusText,
|
|
621
|
+
headers: res.headers
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
function defaultErrorResponse(request, status, error, errorDescription) {
|
|
625
|
+
const accept = request.headers.get("accept") ?? "";
|
|
626
|
+
const wantsHtml = accept.includes("text/html") && request.nextUrl.searchParams.get("format") !== "json";
|
|
627
|
+
if (wantsHtml) {
|
|
628
|
+
return new NextResponse2(buildErrorPage(status, error, errorDescription), {
|
|
629
|
+
status,
|
|
630
|
+
headers: {
|
|
631
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
632
|
+
"Cache-Control": "no-store"
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
return NextResponse2.json({ error, error_description: errorDescription }, { status });
|
|
637
|
+
}
|
|
510
638
|
function createCallbackRouteHandler(config) {
|
|
511
639
|
const {
|
|
512
640
|
clientId,
|
|
@@ -514,6 +642,7 @@ function createCallbackRouteHandler(config) {
|
|
|
514
642
|
redirectUri,
|
|
515
643
|
clientSecret,
|
|
516
644
|
defaultReturnPath = "/",
|
|
645
|
+
scopes,
|
|
517
646
|
insecureLocalDev: insecureLocalDevOpt = false
|
|
518
647
|
} = config;
|
|
519
648
|
const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
|
|
@@ -531,7 +660,21 @@ function createCallbackRouteHandler(config) {
|
|
|
531
660
|
return async function GET(request) {
|
|
532
661
|
const response = await handleCallback(request);
|
|
533
662
|
if (response.status >= 400) {
|
|
663
|
+
if (config.debug) {
|
|
664
|
+
console.warn(
|
|
665
|
+
`[SSO SDK] \u56DE\u8C03\u5931\u8D25 status=${response.status}`,
|
|
666
|
+
request.nextUrl.searchParams.get("error") ?? ""
|
|
667
|
+
);
|
|
668
|
+
}
|
|
534
669
|
response.cookies.set(nonceCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
670
|
+
const returnedState = request.nextUrl.searchParams.get("state");
|
|
671
|
+
if (returnedState && /^[A-Za-z0-9\-._~]{8,512}$/.test(returnedState)) {
|
|
672
|
+
response.cookies.set(
|
|
673
|
+
`${nonceCookieName}_${returnedState}`,
|
|
674
|
+
"",
|
|
675
|
+
getHostCookieOptions(0, secureCookies)
|
|
676
|
+
);
|
|
677
|
+
}
|
|
535
678
|
}
|
|
536
679
|
return response;
|
|
537
680
|
};
|
|
@@ -540,50 +683,75 @@ function createCallbackRouteHandler(config) {
|
|
|
540
683
|
const error = searchParams.get("error");
|
|
541
684
|
if (error) {
|
|
542
685
|
const desc = searchParams.get("error_description") || error;
|
|
543
|
-
return
|
|
544
|
-
|
|
545
|
-
|
|
686
|
+
return buildErrorResponse(
|
|
687
|
+
request,
|
|
688
|
+
400,
|
|
689
|
+
"authorization_failed",
|
|
690
|
+
desc,
|
|
691
|
+
config.renderErrorPage
|
|
546
692
|
);
|
|
547
693
|
}
|
|
548
694
|
const code = searchParams.get("code");
|
|
549
695
|
const returnedState = searchParams.get("state");
|
|
550
696
|
if (!code) {
|
|
551
|
-
return
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
697
|
+
return buildErrorResponse(
|
|
698
|
+
request,
|
|
699
|
+
400,
|
|
700
|
+
"invalid_request",
|
|
701
|
+
"\u767B\u5F55\u4FE1\u606F\u4E0D\u5B8C\u6574\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u767B\u5F55",
|
|
702
|
+
config.renderErrorPage
|
|
557
703
|
);
|
|
558
704
|
}
|
|
559
|
-
|
|
705
|
+
if (!returnedState || !/^[A-Za-z0-9\-._~]{8,512}$/.test(returnedState)) {
|
|
706
|
+
return buildErrorResponse(
|
|
707
|
+
request,
|
|
708
|
+
400,
|
|
709
|
+
"invalid_request",
|
|
710
|
+
"State \u53C2\u6570\u7F3A\u5931\u6216\u683C\u5F0F\u975E\u6CD5\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42",
|
|
711
|
+
config.renderErrorPage
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
const attemptSuffix = `_${returnedState}`;
|
|
715
|
+
const readTransientCookie = (name) => request.cookies.get(`${name}${attemptSuffix}`)?.value ?? request.cookies.get(name)?.value;
|
|
716
|
+
const savedState = readTransientCookie(stateCookieName);
|
|
560
717
|
if (!savedState) {
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
718
|
+
const alternateName = secureCookies ? toInsecureCookieName(stateCookieName) : DEFAULT_STATE_COOKIE_NAME;
|
|
719
|
+
const alternateValue = request.cookies.get(`${alternateName}${attemptSuffix}`)?.value ?? request.cookies.get(alternateName)?.value;
|
|
720
|
+
if (alternateValue) {
|
|
721
|
+
return buildErrorResponse(
|
|
722
|
+
request,
|
|
723
|
+
500,
|
|
724
|
+
"invalid_config",
|
|
725
|
+
"\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",
|
|
726
|
+
config.renderErrorPage
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
return buildErrorResponse(
|
|
730
|
+
request,
|
|
731
|
+
400,
|
|
732
|
+
"invalid_request",
|
|
733
|
+
"\u767B\u5F55\u4F1A\u8BDD\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42",
|
|
734
|
+
config.renderErrorPage
|
|
567
735
|
);
|
|
568
736
|
}
|
|
569
|
-
if (!timingSafeEqualString(returnedState
|
|
570
|
-
return
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
737
|
+
if (!timingSafeEqualString(returnedState, savedState)) {
|
|
738
|
+
return buildErrorResponse(
|
|
739
|
+
request,
|
|
740
|
+
400,
|
|
741
|
+
"invalid_request",
|
|
742
|
+
"\u767B\u5F55\u4F1A\u8BDD\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42",
|
|
743
|
+
config.renderErrorPage
|
|
576
744
|
);
|
|
577
745
|
}
|
|
578
|
-
const expectedNonce =
|
|
579
|
-
const verifier =
|
|
746
|
+
const expectedNonce = readTransientCookie(nonceCookieName);
|
|
747
|
+
const verifier = readTransientCookie(verifierCookieName);
|
|
580
748
|
if (!verifier) {
|
|
581
|
-
return
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
749
|
+
return buildErrorResponse(
|
|
750
|
+
request,
|
|
751
|
+
400,
|
|
752
|
+
"invalid_request",
|
|
753
|
+
"\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",
|
|
754
|
+
config.renderErrorPage
|
|
587
755
|
);
|
|
588
756
|
}
|
|
589
757
|
const tokenEndpoint = `${normalizedServerBase}/api/oauth/token`;
|
|
@@ -604,27 +772,37 @@ function createCallbackRouteHandler(config) {
|
|
|
604
772
|
await new Promise((r) => setTimeout(r, 1e3 * Math.pow(2, attempt - 1)));
|
|
605
773
|
}
|
|
606
774
|
try {
|
|
607
|
-
res = await
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
775
|
+
res = await fetchWithTimeout(
|
|
776
|
+
tokenEndpoint,
|
|
777
|
+
{
|
|
778
|
+
method: "POST",
|
|
779
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
780
|
+
body: body.toString()
|
|
781
|
+
},
|
|
782
|
+
1e4
|
|
783
|
+
);
|
|
612
784
|
lastError = null;
|
|
613
785
|
break;
|
|
614
786
|
} catch (err) {
|
|
615
787
|
lastError = err;
|
|
616
788
|
if (attempt >= maxRetries) {
|
|
617
|
-
return
|
|
618
|
-
|
|
619
|
-
|
|
789
|
+
return buildErrorResponse(
|
|
790
|
+
request,
|
|
791
|
+
502,
|
|
792
|
+
"server_error",
|
|
793
|
+
"\u767B\u5F55\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF08Token \u8BF7\u6C42\u5931\u8D25\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
|
|
794
|
+
config.renderErrorPage
|
|
620
795
|
);
|
|
621
796
|
}
|
|
622
797
|
}
|
|
623
798
|
}
|
|
624
799
|
if (lastError || !res) {
|
|
625
|
-
return
|
|
626
|
-
|
|
627
|
-
|
|
800
|
+
return buildErrorResponse(
|
|
801
|
+
request,
|
|
802
|
+
502,
|
|
803
|
+
"server_error",
|
|
804
|
+
"\u767B\u5F55\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF08Token \u8BF7\u6C42\u5931\u8D25\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
|
|
805
|
+
config.renderErrorPage
|
|
628
806
|
);
|
|
629
807
|
}
|
|
630
808
|
if (!res.ok) {
|
|
@@ -633,22 +811,32 @@ function createCallbackRouteHandler(config) {
|
|
|
633
811
|
errData = await res.json();
|
|
634
812
|
} catch {
|
|
635
813
|
}
|
|
636
|
-
return
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
}
|
|
641
|
-
|
|
814
|
+
return buildErrorResponse(
|
|
815
|
+
request,
|
|
816
|
+
502,
|
|
817
|
+
"token_request_failed",
|
|
818
|
+
errData.error_description || `\u767B\u5F55\u5931\u8D25\uFF08Token \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5`,
|
|
819
|
+
config.renderErrorPage
|
|
642
820
|
);
|
|
643
821
|
}
|
|
644
822
|
const tokenData = await res.json();
|
|
645
|
-
if (!tokenData.access_token || !tokenData.refresh_token) {
|
|
646
|
-
return
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
823
|
+
if (!tokenData.access_token || !tokenData.refresh_token || typeof tokenData.expires_in !== "number" || !Number.isFinite(tokenData.expires_in) || tokenData.expires_in <= 0) {
|
|
824
|
+
return buildErrorResponse(
|
|
825
|
+
request,
|
|
826
|
+
502,
|
|
827
|
+
"server_error",
|
|
828
|
+
"\u767B\u5F55\u670D\u52A1\u8FD4\u56DE\u5F02\u5E38\uFF08Token \u54CD\u5E94\u4E0D\u5B8C\u6574\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
|
|
829
|
+
config.renderErrorPage
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
const requiresIdToken = scopes !== void 0 && scopes.split(" ").filter(Boolean).includes("openid");
|
|
833
|
+
if (requiresIdToken && !tokenData.id_token) {
|
|
834
|
+
return buildErrorResponse(
|
|
835
|
+
request,
|
|
836
|
+
400,
|
|
837
|
+
"id_token_invalid",
|
|
838
|
+
"\u767B\u5F55\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u767B\u5F55",
|
|
839
|
+
config.renderErrorPage
|
|
652
840
|
);
|
|
653
841
|
}
|
|
654
842
|
if (tokenData.id_token) {
|
|
@@ -661,17 +849,17 @@ function createCallbackRouteHandler(config) {
|
|
|
661
849
|
{ expectedNonce }
|
|
662
850
|
);
|
|
663
851
|
} catch (err) {
|
|
664
|
-
return
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
852
|
+
return buildErrorResponse(
|
|
853
|
+
request,
|
|
854
|
+
400,
|
|
855
|
+
"id_token_invalid",
|
|
856
|
+
err instanceof Error && /[\u4e00-\u9fff]/.test(err.message) ? err.message : "\u767B\u5F55\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u767B\u5F55",
|
|
857
|
+
config.renderErrorPage
|
|
670
858
|
);
|
|
671
859
|
}
|
|
672
860
|
}
|
|
673
861
|
const callbackOrigin = new URL(redirectUri).origin;
|
|
674
|
-
const rawReturnUrl =
|
|
862
|
+
const rawReturnUrl = readTransientCookie(returnUrlCookieName) || defaultReturnPath;
|
|
675
863
|
const returnUrl = isTrustedReturnUrl(rawReturnUrl, callbackOrigin) ? rawReturnUrl : "/";
|
|
676
864
|
const response = NextResponse2.redirect(new URL(returnUrl, callbackOrigin));
|
|
677
865
|
response.cookies.set(accessTokenCookieName, tokenData.access_token, {
|
|
@@ -701,11 +889,18 @@ function createCallbackRouteHandler(config) {
|
|
|
701
889
|
});
|
|
702
890
|
}
|
|
703
891
|
}
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
892
|
+
for (const name of [stateCookieName, nonceCookieName, returnUrlCookieName]) {
|
|
893
|
+
response.cookies.set(name, "", getHostCookieOptions(0, secureCookies));
|
|
894
|
+
response.cookies.set(`${name}${attemptSuffix}`, "", getHostCookieOptions(0, secureCookies));
|
|
895
|
+
}
|
|
896
|
+
for (const path of ["/", request.nextUrl.pathname]) {
|
|
897
|
+
response.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, path, secureCookies));
|
|
898
|
+
response.cookies.set(
|
|
899
|
+
`${verifierCookieName}${attemptSuffix}`,
|
|
900
|
+
"",
|
|
901
|
+
getSecureCookieOptions(0, path, secureCookies)
|
|
902
|
+
);
|
|
903
|
+
}
|
|
709
904
|
return response;
|
|
710
905
|
}
|
|
711
906
|
}
|
|
@@ -819,7 +1014,7 @@ function createLogoutRouteHandler(config) {
|
|
|
819
1014
|
const returnedState = request.nextUrl.searchParams.get("state");
|
|
820
1015
|
if (returnedState) {
|
|
821
1016
|
const savedState = request.cookies.get(logoutStateCookieName)?.value;
|
|
822
|
-
if (!savedState || savedState
|
|
1017
|
+
if (!savedState || !timingSafeEqualString(savedState, returnedState)) {
|
|
823
1018
|
return NextResponse3.json(
|
|
824
1019
|
{ error: "invalid_request", error_description: "Logout state \u4E0D\u5339\u914D" },
|
|
825
1020
|
{ status: 400 }
|
|
@@ -835,6 +1030,20 @@ function createLogoutRouteHandler(config) {
|
|
|
835
1030
|
headers: { "content-type": "text/html; charset=utf-8" }
|
|
836
1031
|
});
|
|
837
1032
|
}
|
|
1033
|
+
const requestOrigin = request.headers.get("origin");
|
|
1034
|
+
if (requestOrigin && requestOrigin !== callbackOrigin) {
|
|
1035
|
+
return NextResponse3.json(
|
|
1036
|
+
{ error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" },
|
|
1037
|
+
{ status: 403 }
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
const secFetchSite = request.headers.get("sec-fetch-site");
|
|
1041
|
+
if (!requestOrigin && secFetchSite && secFetchSite !== "same-origin" && secFetchSite !== "none") {
|
|
1042
|
+
return NextResponse3.json(
|
|
1043
|
+
{ error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" },
|
|
1044
|
+
{ status: 403 }
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
838
1047
|
let formGlobal = null;
|
|
839
1048
|
try {
|
|
840
1049
|
formGlobal = new URLSearchParams(await request.text()).get("global");
|
|
@@ -873,6 +1082,21 @@ function createLogoutRouteHandler(config) {
|
|
|
873
1082
|
res.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
874
1083
|
res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/", secureCookies));
|
|
875
1084
|
res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, callbackPath, secureCookies));
|
|
1085
|
+
const suffixedPrefixes = [
|
|
1086
|
+
`${stateCookieName}_`,
|
|
1087
|
+
`${nonceCookieName}_`,
|
|
1088
|
+
`${returnUrlCookieName}_`,
|
|
1089
|
+
`${verifierCookieName}_`
|
|
1090
|
+
];
|
|
1091
|
+
for (const cookie of request.cookies.getAll()) {
|
|
1092
|
+
if (!suffixedPrefixes.some((prefix) => cookie.name.startsWith(prefix))) continue;
|
|
1093
|
+
if (cookie.name.startsWith(`${verifierCookieName}_`)) {
|
|
1094
|
+
res.cookies.set(cookie.name, "", getSecureCookieOptions(0, "/", secureCookies));
|
|
1095
|
+
res.cookies.set(cookie.name, "", getSecureCookieOptions(0, callbackPath, secureCookies));
|
|
1096
|
+
} else {
|
|
1097
|
+
res.cookies.set(cookie.name, "", getHostCookieOptions(0, secureCookies));
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
876
1100
|
return res;
|
|
877
1101
|
};
|
|
878
1102
|
if (effectiveScope === "global") {
|
|
@@ -906,6 +1130,20 @@ import { NextResponse as NextResponse4 } from "next/server";
|
|
|
906
1130
|
var BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
|
|
907
1131
|
var JTI_CACHE_CAPACITY = 1e3;
|
|
908
1132
|
var seenJti = /* @__PURE__ */ new Map();
|
|
1133
|
+
async function releaseLogoutTokenJti(replayKey, options = {}) {
|
|
1134
|
+
const store = options.jtiStore;
|
|
1135
|
+
if (store?.remove) {
|
|
1136
|
+
await store.remove(replayKey);
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
if (store) {
|
|
1140
|
+
console.warn(
|
|
1141
|
+
"[SSO SDK] \u81EA\u5B9A\u4E49 jtiStore \u672A\u5B9E\u73B0 remove()\uFF1AonLogout \u5931\u8D25\u540E\u65E0\u6CD5\u91CA\u653E jti\uFF0CIdP \u7528\u540C\u4E00 logout_token \u91CD\u6295\u5C06\u88AB\u91CD\u653E\u68C0\u67E5\u62D2\u7EDD\uFF08logout_token_replay\uFF09"
|
|
1142
|
+
);
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
seenJti.delete(replayKey);
|
|
1146
|
+
}
|
|
909
1147
|
function recordJti(jti, expiresAtMs) {
|
|
910
1148
|
const now = Date.now();
|
|
911
1149
|
const existing = seenJti.get(jti);
|
|
@@ -921,7 +1159,7 @@ function recordJti(jti, expiresAtMs) {
|
|
|
921
1159
|
seenJti.set(jti, expiresAtMs);
|
|
922
1160
|
return true;
|
|
923
1161
|
}
|
|
924
|
-
async function
|
|
1162
|
+
async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId, options = {}) {
|
|
925
1163
|
const baseUrl = ssoBaseUrl.replace(/\/+$/, "");
|
|
926
1164
|
const header = decodeJwtHeader(logoutToken);
|
|
927
1165
|
if (!header) {
|
|
@@ -998,8 +1236,21 @@ async function verifyLogoutToken(logoutToken, ssoBaseUrl, clientId) {
|
|
|
998
1236
|
if (Date.now() >= payload.exp * 1e3 + 6e4) {
|
|
999
1237
|
throw new SsoError("logout_token_expired", "Logout Token \u5DF2\u8FC7\u671F");
|
|
1000
1238
|
}
|
|
1239
|
+
if (payload.type !== "logout_token") {
|
|
1240
|
+
throw new SsoError(
|
|
1241
|
+
"logout_token_invalid",
|
|
1242
|
+
"Logout Token type \u5FC5\u987B\u4E3A logout_token"
|
|
1243
|
+
);
|
|
1244
|
+
}
|
|
1001
1245
|
const events = payload.events;
|
|
1002
|
-
if (!events || typeof events !== "object" ||
|
|
1246
|
+
if (!events || typeof events !== "object" || Array.isArray(events)) {
|
|
1247
|
+
throw new SsoError(
|
|
1248
|
+
"logout_token_invalid",
|
|
1249
|
+
"Logout Token \u7F3A\u5C11 backchannel-logout events \u58F0\u660E"
|
|
1250
|
+
);
|
|
1251
|
+
}
|
|
1252
|
+
const logoutEvent = events[BACKCHANNEL_LOGOUT_EVENT];
|
|
1253
|
+
if (!logoutEvent || typeof logoutEvent !== "object" || Array.isArray(logoutEvent)) {
|
|
1003
1254
|
throw new SsoError(
|
|
1004
1255
|
"logout_token_invalid",
|
|
1005
1256
|
"Logout Token \u7F3A\u5C11 backchannel-logout events \u58F0\u660E"
|
|
@@ -1017,10 +1268,27 @@ async function verifyLogoutToken(logoutToken, ssoBaseUrl, clientId) {
|
|
|
1017
1268
|
if (!jti) {
|
|
1018
1269
|
throw new SsoError("logout_token_invalid", "Logout Token \u7F3A\u5C11 jti \u58F0\u660E");
|
|
1019
1270
|
}
|
|
1020
|
-
|
|
1271
|
+
const replayKey = `${normalizedIssuer}:${jti}`;
|
|
1272
|
+
const replayTtlSeconds = Math.max(
|
|
1273
|
+
1,
|
|
1274
|
+
Math.ceil((payload.exp * 1e3 + 6e4 - Date.now()) / 1e3)
|
|
1275
|
+
);
|
|
1276
|
+
const jtiStore = options.jtiStore;
|
|
1277
|
+
if (jtiStore) {
|
|
1278
|
+
if (jtiStore.addIfAbsent) {
|
|
1279
|
+
if (!await jtiStore.addIfAbsent(replayKey, replayTtlSeconds)) {
|
|
1280
|
+
throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
|
|
1281
|
+
}
|
|
1282
|
+
} else {
|
|
1283
|
+
if (await jtiStore.has(replayKey)) {
|
|
1284
|
+
throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
|
|
1285
|
+
}
|
|
1286
|
+
await jtiStore.add(replayKey, replayTtlSeconds);
|
|
1287
|
+
}
|
|
1288
|
+
} else if (!recordJti(replayKey, payload.exp * 1e3 + 6e4)) {
|
|
1021
1289
|
throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
|
|
1022
1290
|
}
|
|
1023
|
-
return { sub, sid };
|
|
1291
|
+
return { payload: { sub, sid }, jti, replayKey };
|
|
1024
1292
|
}
|
|
1025
1293
|
|
|
1026
1294
|
// src/next/backchannel-logout.ts
|
|
@@ -1060,9 +1328,11 @@ function createBackchannelLogoutRouteHandler(config) {
|
|
|
1060
1328
|
{ status: 400 }
|
|
1061
1329
|
);
|
|
1062
1330
|
}
|
|
1063
|
-
let
|
|
1331
|
+
let verified;
|
|
1064
1332
|
try {
|
|
1065
|
-
|
|
1333
|
+
verified = await verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId, {
|
|
1334
|
+
jtiStore: config.jtiStore
|
|
1335
|
+
});
|
|
1066
1336
|
} catch (err) {
|
|
1067
1337
|
const code = err instanceof SsoError ? err.code : "logout_token_invalid";
|
|
1068
1338
|
const description = err instanceof SsoError ? err.description : "Logout Token \u9A8C\u8BC1\u5931\u8D25";
|
|
@@ -1072,8 +1342,9 @@ function createBackchannelLogoutRouteHandler(config) {
|
|
|
1072
1342
|
);
|
|
1073
1343
|
}
|
|
1074
1344
|
try {
|
|
1075
|
-
await onLogout?.(payload, request);
|
|
1345
|
+
await onLogout?.(verified.payload, request);
|
|
1076
1346
|
} catch (err) {
|
|
1347
|
+
await releaseLogoutTokenJti(verified.replayKey, { jtiStore: config.jtiStore });
|
|
1077
1348
|
console.error(
|
|
1078
1349
|
"[SSO SDK] backchannel logout onLogout \u94A9\u5B50\u6267\u884C\u5931\u8D25:",
|
|
1079
1350
|
err
|