@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.js
CHANGED
|
@@ -114,15 +114,18 @@ async function computeCodeChallenge(verifier) {
|
|
|
114
114
|
function matchesPath(pathname, paths) {
|
|
115
115
|
return paths.some((path) => {
|
|
116
116
|
if (pathname === path) return true;
|
|
117
|
-
if (path.endsWith("/:path*")
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
if (path.endsWith("/:path*")) {
|
|
118
|
+
const base = path.slice(0, -"/:path*".length);
|
|
119
|
+
return pathname === base || pathname.startsWith(base + "/");
|
|
120
|
+
}
|
|
121
|
+
return pathname.startsWith(path + "/");
|
|
120
122
|
});
|
|
121
123
|
}
|
|
122
124
|
var introspectionCache = /* @__PURE__ */ new Map();
|
|
123
125
|
var INTROSPECT_CACHE_TTL_MS = 3e4;
|
|
124
126
|
var INTROSPECT_CACHE_MAX_ENTRIES = 500;
|
|
125
127
|
var INTROSPECT_TIMEOUT_MS = 5e3;
|
|
128
|
+
var warnedIntrospectClientAuthFailure = false;
|
|
126
129
|
async function introspectCacheKey(token, clientId) {
|
|
127
130
|
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
|
|
128
131
|
const hex = Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
@@ -176,8 +179,13 @@ async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret)
|
|
|
176
179
|
if (res.status !== 401 && res.status !== 403) {
|
|
177
180
|
return "unreachable";
|
|
178
181
|
}
|
|
179
|
-
|
|
180
|
-
|
|
182
|
+
if (!warnedIntrospectClientAuthFailure) {
|
|
183
|
+
warnedIntrospectClientAuthFailure = true;
|
|
184
|
+
console.warn(
|
|
185
|
+
`[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`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
return "unreachable";
|
|
181
189
|
}
|
|
182
190
|
const data = await res.json();
|
|
183
191
|
const active = data.active === true;
|
|
@@ -196,8 +204,7 @@ function createSsoMiddleware(config) {
|
|
|
196
204
|
scopes = "openid profile",
|
|
197
205
|
publicPaths = [],
|
|
198
206
|
callbackPath = "/api/auth/callback",
|
|
199
|
-
|
|
200
|
-
validateSsoCookie = true,
|
|
207
|
+
failClosedOnIntrospectionError = false,
|
|
201
208
|
insecureLocalDev: insecureLocalDevOpt = false
|
|
202
209
|
} = config;
|
|
203
210
|
const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
|
|
@@ -209,9 +216,9 @@ function createSsoMiddleware(config) {
|
|
|
209
216
|
const verifierCookieName = insecureLocalDev ? toInsecureCookieName(config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME) : config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME;
|
|
210
217
|
const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
|
|
211
218
|
const normalizedServerBase = (config.serverBaseUrl ?? ssoBaseUrl).replace(/\/+$/, "");
|
|
212
|
-
if (
|
|
219
|
+
if (config.validateSsoCookie !== void 0 || config.ssoCookieName !== void 0) {
|
|
213
220
|
console.warn(
|
|
214
|
-
|
|
221
|
+
'[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'
|
|
215
222
|
);
|
|
216
223
|
}
|
|
217
224
|
if (!clientSecret) {
|
|
@@ -221,7 +228,7 @@ function createSsoMiddleware(config) {
|
|
|
221
228
|
}
|
|
222
229
|
return async function ssoMiddleware(request) {
|
|
223
230
|
const { pathname } = request.nextUrl;
|
|
224
|
-
if (pathname.startsWith("/_next/") || pathname.startsWith("/favicon.ico") || pathname.match(/\.(ico|png|jpg|jpeg|svg|css|js|woff2?)$/)) {
|
|
231
|
+
if (!pathname.startsWith("/api/") && (pathname.startsWith("/_next/") || pathname.startsWith("/favicon.ico") || pathname.match(/\.(ico|png|jpg|jpeg|svg|css|js|woff2?)$/))) {
|
|
225
232
|
return import_server.NextResponse.next();
|
|
226
233
|
}
|
|
227
234
|
if (pathname === callbackPath) {
|
|
@@ -231,22 +238,6 @@ function createSsoMiddleware(config) {
|
|
|
231
238
|
if (matchesPath(pathname, allPublicPaths)) {
|
|
232
239
|
return import_server.NextResponse.next();
|
|
233
240
|
}
|
|
234
|
-
const ssoSession = request.cookies.get(ssoCookieName);
|
|
235
|
-
if (ssoSession?.value) {
|
|
236
|
-
if (validateSsoCookie) {
|
|
237
|
-
const introspectResult = await introspectAccessToken(
|
|
238
|
-
ssoSession.value,
|
|
239
|
-
normalizedServerBase,
|
|
240
|
-
clientId,
|
|
241
|
-
clientSecret
|
|
242
|
-
);
|
|
243
|
-
if (introspectResult === "active") {
|
|
244
|
-
return import_server.NextResponse.next();
|
|
245
|
-
}
|
|
246
|
-
} else {
|
|
247
|
-
return import_server.NextResponse.next();
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
241
|
const accessTokenCookie = request.cookies.get(accessTokenCookieName);
|
|
251
242
|
if (accessTokenCookie?.value) {
|
|
252
243
|
const introspectResult = await introspectAccessToken(
|
|
@@ -259,6 +250,15 @@ function createSsoMiddleware(config) {
|
|
|
259
250
|
return import_server.NextResponse.next();
|
|
260
251
|
}
|
|
261
252
|
if (introspectResult === "unreachable") {
|
|
253
|
+
if (failClosedOnIntrospectionError) {
|
|
254
|
+
console.warn(
|
|
255
|
+
"[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"
|
|
256
|
+
);
|
|
257
|
+
return new import_server.NextResponse(
|
|
258
|
+
"SSO \u8BA4\u8BC1\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF08introspection unreachable\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
|
|
259
|
+
{ status: 502 }
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
262
|
console.warn(
|
|
263
263
|
"[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"
|
|
264
264
|
);
|
|
@@ -281,11 +281,28 @@ function createSsoMiddleware(config) {
|
|
|
281
281
|
const loginUrl = new URL("/api/oauth/authorize", normalizedBase);
|
|
282
282
|
loginUrl.search = authorizeParams.toString();
|
|
283
283
|
const response = import_server.NextResponse.redirect(loginUrl);
|
|
284
|
-
|
|
285
|
-
response.cookies.set(
|
|
286
|
-
|
|
284
|
+
const attemptSuffix = `_${state}`;
|
|
285
|
+
response.cookies.set(
|
|
286
|
+
`${stateCookieName}${attemptSuffix}`,
|
|
287
|
+
state,
|
|
288
|
+
getHostCookieOptions(600, secureCookies)
|
|
289
|
+
);
|
|
290
|
+
response.cookies.set(
|
|
291
|
+
`${nonceCookieName}${attemptSuffix}`,
|
|
292
|
+
nonce,
|
|
293
|
+
getHostCookieOptions(600, secureCookies)
|
|
294
|
+
);
|
|
295
|
+
response.cookies.set(
|
|
296
|
+
`${verifierCookieName}${attemptSuffix}`,
|
|
297
|
+
verifier,
|
|
298
|
+
getSecureCookieOptions(600, callbackPath, secureCookies)
|
|
299
|
+
);
|
|
287
300
|
const safeReturnUrl = (request.nextUrl.pathname + request.nextUrl.search).slice(0, 2048);
|
|
288
|
-
response.cookies.set(
|
|
301
|
+
response.cookies.set(
|
|
302
|
+
`${returnUrlCookieName}${attemptSuffix}`,
|
|
303
|
+
safeReturnUrl,
|
|
304
|
+
getHostCookieOptions(600, secureCookies)
|
|
305
|
+
);
|
|
289
306
|
if (accessTokenCookie?.value) {
|
|
290
307
|
response.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
291
308
|
}
|
|
@@ -308,12 +325,17 @@ var SsoError = class extends Error {
|
|
|
308
325
|
};
|
|
309
326
|
|
|
310
327
|
// src/core/security.ts
|
|
328
|
+
var UNSAFE_URL_CHAR_PATTERN = /[\\\u0000-\u001F\u007F]/;
|
|
311
329
|
function isTrustedReturnUrl(url, currentOrigin) {
|
|
312
330
|
if (!url) return false;
|
|
313
|
-
if (
|
|
314
|
-
if (url.startsWith("
|
|
331
|
+
if (UNSAFE_URL_CHAR_PATTERN.test(url)) return false;
|
|
332
|
+
if (url.startsWith("//")) return false;
|
|
333
|
+
if (url.startsWith("/")) return true;
|
|
315
334
|
try {
|
|
316
|
-
|
|
335
|
+
const parsed = new URL(url);
|
|
336
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
|
|
337
|
+
if (parsed.username || parsed.password) return false;
|
|
338
|
+
return parsed.origin === currentOrigin;
|
|
317
339
|
} catch {
|
|
318
340
|
return false;
|
|
319
341
|
}
|
|
@@ -525,7 +547,10 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
|
|
|
525
547
|
if (Date.now() >= payload.exp * 1e3 + 6e4) {
|
|
526
548
|
throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
|
|
527
549
|
}
|
|
528
|
-
if (typeof payload.iat
|
|
550
|
+
if (typeof payload.iat !== "number") {
|
|
551
|
+
throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 iat \u58F0\u660E");
|
|
552
|
+
}
|
|
553
|
+
if (payload.iat * 1e3 > Date.now() + 6e4) {
|
|
529
554
|
throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
|
|
530
555
|
}
|
|
531
556
|
if (typeof payload.sub !== "string" || !payload.sub) {
|
|
@@ -542,11 +567,114 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
|
|
|
542
567
|
if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
|
|
543
568
|
throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
|
|
544
569
|
}
|
|
570
|
+
} else if (typeof payload.nonce === "string" && payload.nonce) {
|
|
571
|
+
throw new SsoError(
|
|
572
|
+
"id_token_nonce_mismatch",
|
|
573
|
+
"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"
|
|
574
|
+
);
|
|
545
575
|
}
|
|
546
576
|
return { sub: payload.sub };
|
|
547
577
|
}
|
|
548
578
|
|
|
579
|
+
// src/core/http.ts
|
|
580
|
+
var REQUEST_TIMEOUT_MS = 1e4;
|
|
581
|
+
async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
582
|
+
const controller = new AbortController();
|
|
583
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
584
|
+
try {
|
|
585
|
+
return await fetch(input, { ...init, signal: controller.signal });
|
|
586
|
+
} finally {
|
|
587
|
+
clearTimeout(timer);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
549
591
|
// src/next/callback.ts
|
|
592
|
+
function escapeHtml(value) {
|
|
593
|
+
return value.replace(/[&<>"']/g, (ch) => {
|
|
594
|
+
switch (ch) {
|
|
595
|
+
case "&":
|
|
596
|
+
return "&";
|
|
597
|
+
case "<":
|
|
598
|
+
return "<";
|
|
599
|
+
case ">":
|
|
600
|
+
return ">";
|
|
601
|
+
case '"':
|
|
602
|
+
return """;
|
|
603
|
+
default:
|
|
604
|
+
return "'";
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
function buildErrorPage(status, error, errorDescription) {
|
|
609
|
+
const safeDescription = escapeHtml(errorDescription);
|
|
610
|
+
const safeError = escapeHtml(error);
|
|
611
|
+
return `<!doctype html>
|
|
612
|
+
<html lang="zh-CN">
|
|
613
|
+
<head>
|
|
614
|
+
<meta charset="utf-8" />
|
|
615
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
616
|
+
<meta name="robots" content="noindex" />
|
|
617
|
+
<title>\u767B\u5F55\u5931\u8D25</title>
|
|
618
|
+
<style>
|
|
619
|
+
:root { color-scheme: light; }
|
|
620
|
+
body { margin: 0; font-family: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; background: #fafafa; color: #2c2c2c; }
|
|
621
|
+
main { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem; text-align: center; }
|
|
622
|
+
h1 { font-size: 1.25rem; font-weight: 500; letter-spacing: 0.08em; margin: 0 0 0.75rem; }
|
|
623
|
+
p { margin: 0 0 1.5rem; color: #6b7280; font-size: 0.875rem; line-height: 1.6; max-width: 28rem; }
|
|
624
|
+
.actions { display: flex; gap: 0.75rem; }
|
|
625
|
+
a { display: inline-block; padding: 0.6rem 1.4rem; font-size: 0.8125rem; letter-spacing: 0.08em; text-decoration: none; }
|
|
626
|
+
a.primary { background: #2c2c2c; color: #fff; }
|
|
627
|
+
a.secondary { border: 1px solid rgba(44, 44, 44, 0.25); color: #2c2c2c; }
|
|
628
|
+
.code { margin-top: 2rem; font-size: 0.6875rem; color: #9ca3af; }
|
|
629
|
+
</style>
|
|
630
|
+
</head>
|
|
631
|
+
<body>
|
|
632
|
+
<main>
|
|
633
|
+
<h1>\u767B\u5F55\u5931\u8D25</h1>
|
|
634
|
+
<p>${safeDescription}</p>
|
|
635
|
+
<div class="actions">
|
|
636
|
+
<a class="primary" href="/">\u91CD\u65B0\u767B\u5F55</a>
|
|
637
|
+
<a class="secondary" href="/">\u8FD4\u56DE\u9996\u9875</a>
|
|
638
|
+
</div>
|
|
639
|
+
<p class="code">\u9519\u8BEF\u7801\uFF1A${safeError}\uFF08\u53CD\u9988\u95EE\u9898\u65F6\u8BF7\u9644\u4E0A\uFF09</p>
|
|
640
|
+
</main>
|
|
641
|
+
</body>
|
|
642
|
+
</html>`;
|
|
643
|
+
}
|
|
644
|
+
function buildErrorResponse(request, status, error, errorDescription, custom) {
|
|
645
|
+
if (custom) {
|
|
646
|
+
const handled = custom({ error, errorDescription, status, request });
|
|
647
|
+
if (handled instanceof Promise) {
|
|
648
|
+
return handled.then(
|
|
649
|
+
(res) => res ? toNextResponse(res) : defaultErrorResponse(request, status, error, errorDescription)
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
if (handled) return toNextResponse(handled);
|
|
653
|
+
}
|
|
654
|
+
return defaultErrorResponse(request, status, error, errorDescription);
|
|
655
|
+
}
|
|
656
|
+
function toNextResponse(res) {
|
|
657
|
+
if (res instanceof import_server2.NextResponse) return res;
|
|
658
|
+
return new import_server2.NextResponse(res.body, {
|
|
659
|
+
status: res.status,
|
|
660
|
+
statusText: res.statusText,
|
|
661
|
+
headers: res.headers
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
function defaultErrorResponse(request, status, error, errorDescription) {
|
|
665
|
+
const accept = request.headers.get("accept") ?? "";
|
|
666
|
+
const wantsHtml = accept.includes("text/html") && request.nextUrl.searchParams.get("format") !== "json";
|
|
667
|
+
if (wantsHtml) {
|
|
668
|
+
return new import_server2.NextResponse(buildErrorPage(status, error, errorDescription), {
|
|
669
|
+
status,
|
|
670
|
+
headers: {
|
|
671
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
672
|
+
"Cache-Control": "no-store"
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
return import_server2.NextResponse.json({ error, error_description: errorDescription }, { status });
|
|
677
|
+
}
|
|
550
678
|
function createCallbackRouteHandler(config) {
|
|
551
679
|
const {
|
|
552
680
|
clientId,
|
|
@@ -554,6 +682,7 @@ function createCallbackRouteHandler(config) {
|
|
|
554
682
|
redirectUri,
|
|
555
683
|
clientSecret,
|
|
556
684
|
defaultReturnPath = "/",
|
|
685
|
+
scopes,
|
|
557
686
|
insecureLocalDev: insecureLocalDevOpt = false
|
|
558
687
|
} = config;
|
|
559
688
|
const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
|
|
@@ -571,7 +700,21 @@ function createCallbackRouteHandler(config) {
|
|
|
571
700
|
return async function GET(request) {
|
|
572
701
|
const response = await handleCallback(request);
|
|
573
702
|
if (response.status >= 400) {
|
|
703
|
+
if (config.debug) {
|
|
704
|
+
console.warn(
|
|
705
|
+
`[SSO SDK] \u56DE\u8C03\u5931\u8D25 status=${response.status}`,
|
|
706
|
+
request.nextUrl.searchParams.get("error") ?? ""
|
|
707
|
+
);
|
|
708
|
+
}
|
|
574
709
|
response.cookies.set(nonceCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
710
|
+
const returnedState = request.nextUrl.searchParams.get("state");
|
|
711
|
+
if (returnedState && /^[A-Za-z0-9\-._~]{8,512}$/.test(returnedState)) {
|
|
712
|
+
response.cookies.set(
|
|
713
|
+
`${nonceCookieName}_${returnedState}`,
|
|
714
|
+
"",
|
|
715
|
+
getHostCookieOptions(0, secureCookies)
|
|
716
|
+
);
|
|
717
|
+
}
|
|
575
718
|
}
|
|
576
719
|
return response;
|
|
577
720
|
};
|
|
@@ -580,50 +723,75 @@ function createCallbackRouteHandler(config) {
|
|
|
580
723
|
const error = searchParams.get("error");
|
|
581
724
|
if (error) {
|
|
582
725
|
const desc = searchParams.get("error_description") || error;
|
|
583
|
-
return
|
|
584
|
-
|
|
585
|
-
|
|
726
|
+
return buildErrorResponse(
|
|
727
|
+
request,
|
|
728
|
+
400,
|
|
729
|
+
"authorization_failed",
|
|
730
|
+
desc,
|
|
731
|
+
config.renderErrorPage
|
|
586
732
|
);
|
|
587
733
|
}
|
|
588
734
|
const code = searchParams.get("code");
|
|
589
735
|
const returnedState = searchParams.get("state");
|
|
590
736
|
if (!code) {
|
|
591
|
-
return
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
737
|
+
return buildErrorResponse(
|
|
738
|
+
request,
|
|
739
|
+
400,
|
|
740
|
+
"invalid_request",
|
|
741
|
+
"\u767B\u5F55\u4FE1\u606F\u4E0D\u5B8C\u6574\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u767B\u5F55",
|
|
742
|
+
config.renderErrorPage
|
|
597
743
|
);
|
|
598
744
|
}
|
|
599
|
-
|
|
745
|
+
if (!returnedState || !/^[A-Za-z0-9\-._~]{8,512}$/.test(returnedState)) {
|
|
746
|
+
return buildErrorResponse(
|
|
747
|
+
request,
|
|
748
|
+
400,
|
|
749
|
+
"invalid_request",
|
|
750
|
+
"State \u53C2\u6570\u7F3A\u5931\u6216\u683C\u5F0F\u975E\u6CD5\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42",
|
|
751
|
+
config.renderErrorPage
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
const attemptSuffix = `_${returnedState}`;
|
|
755
|
+
const readTransientCookie = (name) => request.cookies.get(`${name}${attemptSuffix}`)?.value ?? request.cookies.get(name)?.value;
|
|
756
|
+
const savedState = readTransientCookie(stateCookieName);
|
|
600
757
|
if (!savedState) {
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
758
|
+
const alternateName = secureCookies ? toInsecureCookieName(stateCookieName) : DEFAULT_STATE_COOKIE_NAME;
|
|
759
|
+
const alternateValue = request.cookies.get(`${alternateName}${attemptSuffix}`)?.value ?? request.cookies.get(alternateName)?.value;
|
|
760
|
+
if (alternateValue) {
|
|
761
|
+
return buildErrorResponse(
|
|
762
|
+
request,
|
|
763
|
+
500,
|
|
764
|
+
"invalid_config",
|
|
765
|
+
"\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",
|
|
766
|
+
config.renderErrorPage
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
return buildErrorResponse(
|
|
770
|
+
request,
|
|
771
|
+
400,
|
|
772
|
+
"invalid_request",
|
|
773
|
+
"\u767B\u5F55\u4F1A\u8BDD\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42",
|
|
774
|
+
config.renderErrorPage
|
|
607
775
|
);
|
|
608
776
|
}
|
|
609
|
-
if (!timingSafeEqualString(returnedState
|
|
610
|
-
return
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
777
|
+
if (!timingSafeEqualString(returnedState, savedState)) {
|
|
778
|
+
return buildErrorResponse(
|
|
779
|
+
request,
|
|
780
|
+
400,
|
|
781
|
+
"invalid_request",
|
|
782
|
+
"\u767B\u5F55\u4F1A\u8BDD\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u6388\u6743\u8BF7\u6C42",
|
|
783
|
+
config.renderErrorPage
|
|
616
784
|
);
|
|
617
785
|
}
|
|
618
|
-
const expectedNonce =
|
|
619
|
-
const verifier =
|
|
786
|
+
const expectedNonce = readTransientCookie(nonceCookieName);
|
|
787
|
+
const verifier = readTransientCookie(verifierCookieName);
|
|
620
788
|
if (!verifier) {
|
|
621
|
-
return
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
789
|
+
return buildErrorResponse(
|
|
790
|
+
request,
|
|
791
|
+
400,
|
|
792
|
+
"invalid_request",
|
|
793
|
+
"\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",
|
|
794
|
+
config.renderErrorPage
|
|
627
795
|
);
|
|
628
796
|
}
|
|
629
797
|
const tokenEndpoint = `${normalizedServerBase}/api/oauth/token`;
|
|
@@ -644,27 +812,37 @@ function createCallbackRouteHandler(config) {
|
|
|
644
812
|
await new Promise((r) => setTimeout(r, 1e3 * Math.pow(2, attempt - 1)));
|
|
645
813
|
}
|
|
646
814
|
try {
|
|
647
|
-
res = await
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
815
|
+
res = await fetchWithTimeout(
|
|
816
|
+
tokenEndpoint,
|
|
817
|
+
{
|
|
818
|
+
method: "POST",
|
|
819
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
820
|
+
body: body.toString()
|
|
821
|
+
},
|
|
822
|
+
1e4
|
|
823
|
+
);
|
|
652
824
|
lastError = null;
|
|
653
825
|
break;
|
|
654
826
|
} catch (err) {
|
|
655
827
|
lastError = err;
|
|
656
828
|
if (attempt >= maxRetries) {
|
|
657
|
-
return
|
|
658
|
-
|
|
659
|
-
|
|
829
|
+
return buildErrorResponse(
|
|
830
|
+
request,
|
|
831
|
+
502,
|
|
832
|
+
"server_error",
|
|
833
|
+
"\u767B\u5F55\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF08Token \u8BF7\u6C42\u5931\u8D25\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
|
|
834
|
+
config.renderErrorPage
|
|
660
835
|
);
|
|
661
836
|
}
|
|
662
837
|
}
|
|
663
838
|
}
|
|
664
839
|
if (lastError || !res) {
|
|
665
|
-
return
|
|
666
|
-
|
|
667
|
-
|
|
840
|
+
return buildErrorResponse(
|
|
841
|
+
request,
|
|
842
|
+
502,
|
|
843
|
+
"server_error",
|
|
844
|
+
"\u767B\u5F55\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF08Token \u8BF7\u6C42\u5931\u8D25\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
|
|
845
|
+
config.renderErrorPage
|
|
668
846
|
);
|
|
669
847
|
}
|
|
670
848
|
if (!res.ok) {
|
|
@@ -673,22 +851,32 @@ function createCallbackRouteHandler(config) {
|
|
|
673
851
|
errData = await res.json();
|
|
674
852
|
} catch {
|
|
675
853
|
}
|
|
676
|
-
return
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
}
|
|
681
|
-
|
|
854
|
+
return buildErrorResponse(
|
|
855
|
+
request,
|
|
856
|
+
502,
|
|
857
|
+
"token_request_failed",
|
|
858
|
+
errData.error_description || `\u767B\u5F55\u5931\u8D25\uFF08Token \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5`,
|
|
859
|
+
config.renderErrorPage
|
|
682
860
|
);
|
|
683
861
|
}
|
|
684
862
|
const tokenData = await res.json();
|
|
685
|
-
if (!tokenData.access_token || !tokenData.refresh_token) {
|
|
686
|
-
return
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
863
|
+
if (!tokenData.access_token || !tokenData.refresh_token || typeof tokenData.expires_in !== "number" || !Number.isFinite(tokenData.expires_in) || tokenData.expires_in <= 0) {
|
|
864
|
+
return buildErrorResponse(
|
|
865
|
+
request,
|
|
866
|
+
502,
|
|
867
|
+
"server_error",
|
|
868
|
+
"\u767B\u5F55\u670D\u52A1\u8FD4\u56DE\u5F02\u5E38\uFF08Token \u54CD\u5E94\u4E0D\u5B8C\u6574\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
|
|
869
|
+
config.renderErrorPage
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
const requiresIdToken = scopes !== void 0 && scopes.split(" ").filter(Boolean).includes("openid");
|
|
873
|
+
if (requiresIdToken && !tokenData.id_token) {
|
|
874
|
+
return buildErrorResponse(
|
|
875
|
+
request,
|
|
876
|
+
400,
|
|
877
|
+
"id_token_invalid",
|
|
878
|
+
"\u767B\u5F55\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u767B\u5F55",
|
|
879
|
+
config.renderErrorPage
|
|
692
880
|
);
|
|
693
881
|
}
|
|
694
882
|
if (tokenData.id_token) {
|
|
@@ -701,17 +889,17 @@ function createCallbackRouteHandler(config) {
|
|
|
701
889
|
{ expectedNonce }
|
|
702
890
|
);
|
|
703
891
|
} catch (err) {
|
|
704
|
-
return
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
892
|
+
return buildErrorResponse(
|
|
893
|
+
request,
|
|
894
|
+
400,
|
|
895
|
+
"id_token_invalid",
|
|
896
|
+
err instanceof Error && /[\u4e00-\u9fff]/.test(err.message) ? err.message : "\u767B\u5F55\u6821\u9A8C\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u767B\u5F55",
|
|
897
|
+
config.renderErrorPage
|
|
710
898
|
);
|
|
711
899
|
}
|
|
712
900
|
}
|
|
713
901
|
const callbackOrigin = new URL(redirectUri).origin;
|
|
714
|
-
const rawReturnUrl =
|
|
902
|
+
const rawReturnUrl = readTransientCookie(returnUrlCookieName) || defaultReturnPath;
|
|
715
903
|
const returnUrl = isTrustedReturnUrl(rawReturnUrl, callbackOrigin) ? rawReturnUrl : "/";
|
|
716
904
|
const response = import_server2.NextResponse.redirect(new URL(returnUrl, callbackOrigin));
|
|
717
905
|
response.cookies.set(accessTokenCookieName, tokenData.access_token, {
|
|
@@ -741,11 +929,18 @@ function createCallbackRouteHandler(config) {
|
|
|
741
929
|
});
|
|
742
930
|
}
|
|
743
931
|
}
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
932
|
+
for (const name of [stateCookieName, nonceCookieName, returnUrlCookieName]) {
|
|
933
|
+
response.cookies.set(name, "", getHostCookieOptions(0, secureCookies));
|
|
934
|
+
response.cookies.set(`${name}${attemptSuffix}`, "", getHostCookieOptions(0, secureCookies));
|
|
935
|
+
}
|
|
936
|
+
for (const path of ["/", request.nextUrl.pathname]) {
|
|
937
|
+
response.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, path, secureCookies));
|
|
938
|
+
response.cookies.set(
|
|
939
|
+
`${verifierCookieName}${attemptSuffix}`,
|
|
940
|
+
"",
|
|
941
|
+
getSecureCookieOptions(0, path, secureCookies)
|
|
942
|
+
);
|
|
943
|
+
}
|
|
749
944
|
return response;
|
|
750
945
|
}
|
|
751
946
|
}
|
|
@@ -859,7 +1054,7 @@ function createLogoutRouteHandler(config) {
|
|
|
859
1054
|
const returnedState = request.nextUrl.searchParams.get("state");
|
|
860
1055
|
if (returnedState) {
|
|
861
1056
|
const savedState = request.cookies.get(logoutStateCookieName)?.value;
|
|
862
|
-
if (!savedState || savedState
|
|
1057
|
+
if (!savedState || !timingSafeEqualString(savedState, returnedState)) {
|
|
863
1058
|
return import_server3.NextResponse.json(
|
|
864
1059
|
{ error: "invalid_request", error_description: "Logout state \u4E0D\u5339\u914D" },
|
|
865
1060
|
{ status: 400 }
|
|
@@ -875,6 +1070,20 @@ function createLogoutRouteHandler(config) {
|
|
|
875
1070
|
headers: { "content-type": "text/html; charset=utf-8" }
|
|
876
1071
|
});
|
|
877
1072
|
}
|
|
1073
|
+
const requestOrigin = request.headers.get("origin");
|
|
1074
|
+
if (requestOrigin && requestOrigin !== callbackOrigin) {
|
|
1075
|
+
return import_server3.NextResponse.json(
|
|
1076
|
+
{ error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" },
|
|
1077
|
+
{ status: 403 }
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
const secFetchSite = request.headers.get("sec-fetch-site");
|
|
1081
|
+
if (!requestOrigin && secFetchSite && secFetchSite !== "same-origin" && secFetchSite !== "none") {
|
|
1082
|
+
return import_server3.NextResponse.json(
|
|
1083
|
+
{ error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" },
|
|
1084
|
+
{ status: 403 }
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
878
1087
|
let formGlobal = null;
|
|
879
1088
|
try {
|
|
880
1089
|
formGlobal = new URLSearchParams(await request.text()).get("global");
|
|
@@ -913,6 +1122,21 @@ function createLogoutRouteHandler(config) {
|
|
|
913
1122
|
res.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
914
1123
|
res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/", secureCookies));
|
|
915
1124
|
res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, callbackPath, secureCookies));
|
|
1125
|
+
const suffixedPrefixes = [
|
|
1126
|
+
`${stateCookieName}_`,
|
|
1127
|
+
`${nonceCookieName}_`,
|
|
1128
|
+
`${returnUrlCookieName}_`,
|
|
1129
|
+
`${verifierCookieName}_`
|
|
1130
|
+
];
|
|
1131
|
+
for (const cookie of request.cookies.getAll()) {
|
|
1132
|
+
if (!suffixedPrefixes.some((prefix) => cookie.name.startsWith(prefix))) continue;
|
|
1133
|
+
if (cookie.name.startsWith(`${verifierCookieName}_`)) {
|
|
1134
|
+
res.cookies.set(cookie.name, "", getSecureCookieOptions(0, "/", secureCookies));
|
|
1135
|
+
res.cookies.set(cookie.name, "", getSecureCookieOptions(0, callbackPath, secureCookies));
|
|
1136
|
+
} else {
|
|
1137
|
+
res.cookies.set(cookie.name, "", getHostCookieOptions(0, secureCookies));
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
916
1140
|
return res;
|
|
917
1141
|
};
|
|
918
1142
|
if (effectiveScope === "global") {
|
|
@@ -946,6 +1170,20 @@ var import_server4 = require("next/server");
|
|
|
946
1170
|
var BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
|
|
947
1171
|
var JTI_CACHE_CAPACITY = 1e3;
|
|
948
1172
|
var seenJti = /* @__PURE__ */ new Map();
|
|
1173
|
+
async function releaseLogoutTokenJti(replayKey, options = {}) {
|
|
1174
|
+
const store = options.jtiStore;
|
|
1175
|
+
if (store?.remove) {
|
|
1176
|
+
await store.remove(replayKey);
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
if (store) {
|
|
1180
|
+
console.warn(
|
|
1181
|
+
"[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"
|
|
1182
|
+
);
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
seenJti.delete(replayKey);
|
|
1186
|
+
}
|
|
949
1187
|
function recordJti(jti, expiresAtMs) {
|
|
950
1188
|
const now = Date.now();
|
|
951
1189
|
const existing = seenJti.get(jti);
|
|
@@ -961,7 +1199,7 @@ function recordJti(jti, expiresAtMs) {
|
|
|
961
1199
|
seenJti.set(jti, expiresAtMs);
|
|
962
1200
|
return true;
|
|
963
1201
|
}
|
|
964
|
-
async function
|
|
1202
|
+
async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId, options = {}) {
|
|
965
1203
|
const baseUrl = ssoBaseUrl.replace(/\/+$/, "");
|
|
966
1204
|
const header = decodeJwtHeader(logoutToken);
|
|
967
1205
|
if (!header) {
|
|
@@ -1038,8 +1276,21 @@ async function verifyLogoutToken(logoutToken, ssoBaseUrl, clientId) {
|
|
|
1038
1276
|
if (Date.now() >= payload.exp * 1e3 + 6e4) {
|
|
1039
1277
|
throw new SsoError("logout_token_expired", "Logout Token \u5DF2\u8FC7\u671F");
|
|
1040
1278
|
}
|
|
1279
|
+
if (payload.type !== "logout_token") {
|
|
1280
|
+
throw new SsoError(
|
|
1281
|
+
"logout_token_invalid",
|
|
1282
|
+
"Logout Token type \u5FC5\u987B\u4E3A logout_token"
|
|
1283
|
+
);
|
|
1284
|
+
}
|
|
1041
1285
|
const events = payload.events;
|
|
1042
|
-
if (!events || typeof events !== "object" ||
|
|
1286
|
+
if (!events || typeof events !== "object" || Array.isArray(events)) {
|
|
1287
|
+
throw new SsoError(
|
|
1288
|
+
"logout_token_invalid",
|
|
1289
|
+
"Logout Token \u7F3A\u5C11 backchannel-logout events \u58F0\u660E"
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1292
|
+
const logoutEvent = events[BACKCHANNEL_LOGOUT_EVENT];
|
|
1293
|
+
if (!logoutEvent || typeof logoutEvent !== "object" || Array.isArray(logoutEvent)) {
|
|
1043
1294
|
throw new SsoError(
|
|
1044
1295
|
"logout_token_invalid",
|
|
1045
1296
|
"Logout Token \u7F3A\u5C11 backchannel-logout events \u58F0\u660E"
|
|
@@ -1057,10 +1308,27 @@ async function verifyLogoutToken(logoutToken, ssoBaseUrl, clientId) {
|
|
|
1057
1308
|
if (!jti) {
|
|
1058
1309
|
throw new SsoError("logout_token_invalid", "Logout Token \u7F3A\u5C11 jti \u58F0\u660E");
|
|
1059
1310
|
}
|
|
1060
|
-
|
|
1311
|
+
const replayKey = `${normalizedIssuer}:${jti}`;
|
|
1312
|
+
const replayTtlSeconds = Math.max(
|
|
1313
|
+
1,
|
|
1314
|
+
Math.ceil((payload.exp * 1e3 + 6e4 - Date.now()) / 1e3)
|
|
1315
|
+
);
|
|
1316
|
+
const jtiStore = options.jtiStore;
|
|
1317
|
+
if (jtiStore) {
|
|
1318
|
+
if (jtiStore.addIfAbsent) {
|
|
1319
|
+
if (!await jtiStore.addIfAbsent(replayKey, replayTtlSeconds)) {
|
|
1320
|
+
throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
|
|
1321
|
+
}
|
|
1322
|
+
} else {
|
|
1323
|
+
if (await jtiStore.has(replayKey)) {
|
|
1324
|
+
throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
|
|
1325
|
+
}
|
|
1326
|
+
await jtiStore.add(replayKey, replayTtlSeconds);
|
|
1327
|
+
}
|
|
1328
|
+
} else if (!recordJti(replayKey, payload.exp * 1e3 + 6e4)) {
|
|
1061
1329
|
throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
|
|
1062
1330
|
}
|
|
1063
|
-
return { sub, sid };
|
|
1331
|
+
return { payload: { sub, sid }, jti, replayKey };
|
|
1064
1332
|
}
|
|
1065
1333
|
|
|
1066
1334
|
// src/next/backchannel-logout.ts
|
|
@@ -1100,9 +1368,11 @@ function createBackchannelLogoutRouteHandler(config) {
|
|
|
1100
1368
|
{ status: 400 }
|
|
1101
1369
|
);
|
|
1102
1370
|
}
|
|
1103
|
-
let
|
|
1371
|
+
let verified;
|
|
1104
1372
|
try {
|
|
1105
|
-
|
|
1373
|
+
verified = await verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId, {
|
|
1374
|
+
jtiStore: config.jtiStore
|
|
1375
|
+
});
|
|
1106
1376
|
} catch (err) {
|
|
1107
1377
|
const code = err instanceof SsoError ? err.code : "logout_token_invalid";
|
|
1108
1378
|
const description = err instanceof SsoError ? err.description : "Logout Token \u9A8C\u8BC1\u5931\u8D25";
|
|
@@ -1112,8 +1382,9 @@ function createBackchannelLogoutRouteHandler(config) {
|
|
|
1112
1382
|
);
|
|
1113
1383
|
}
|
|
1114
1384
|
try {
|
|
1115
|
-
await onLogout?.(payload, request);
|
|
1385
|
+
await onLogout?.(verified.payload, request);
|
|
1116
1386
|
} catch (err) {
|
|
1387
|
+
await releaseLogoutTokenJti(verified.replayKey, { jtiStore: config.jtiStore });
|
|
1117
1388
|
console.error(
|
|
1118
1389
|
"[SSO SDK] backchannel logout onLogout \u94A9\u5B50\u6267\u884C\u5931\u8D25:",
|
|
1119
1390
|
err
|