@nihplod/sso-sdk 1.5.0 → 1.7.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 +21 -2
- package/dist/index.d.mts +26 -1
- package/dist/index.d.ts +26 -1
- package/dist/index.js +148 -84
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +148 -84
- package/dist/index.mjs.map +1 -1
- package/dist/next/index.d.mts +87 -2
- package/dist/next/index.d.ts +87 -2
- package/dist/next/index.js +330 -64
- package/dist/next/index.js.map +1 -1
- package/dist/next/index.mjs +329 -64
- package/dist/next/index.mjs.map +1 -1
- package/dist/react/index.d.mts +32 -2
- package/dist/react/index.d.ts +32 -2
- package/dist/react/index.js +196 -105
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +196 -105
- package/dist/react/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/next/index.js
CHANGED
|
@@ -31,6 +31,7 @@ __export(next_exports, {
|
|
|
31
31
|
createBackchannelLogoutRouteHandler: () => createBackchannelLogoutRouteHandler,
|
|
32
32
|
createCallbackRouteHandler: () => createCallbackRouteHandler,
|
|
33
33
|
createLogoutRouteHandler: () => createLogoutRouteHandler,
|
|
34
|
+
createRefreshRouteHandler: () => createRefreshRouteHandler,
|
|
34
35
|
createSsoMiddleware: () => createSsoMiddleware,
|
|
35
36
|
getHostCookieOptions: () => getHostCookieOptions,
|
|
36
37
|
getSecureCookieOptions: () => getSecureCookieOptions,
|
|
@@ -114,15 +115,19 @@ async function computeCodeChallenge(verifier) {
|
|
|
114
115
|
function matchesPath(pathname, paths) {
|
|
115
116
|
return paths.some((path) => {
|
|
116
117
|
if (pathname === path) return true;
|
|
117
|
-
if (path.endsWith("/:path*")
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
if (path.endsWith("/:path*")) {
|
|
119
|
+
const base = path.slice(0, -"/:path*".length);
|
|
120
|
+
return pathname === base || pathname.startsWith(base + "/");
|
|
121
|
+
}
|
|
122
|
+
return pathname.startsWith(path + "/");
|
|
120
123
|
});
|
|
121
124
|
}
|
|
122
125
|
var introspectionCache = /* @__PURE__ */ new Map();
|
|
123
126
|
var INTROSPECT_CACHE_TTL_MS = 3e4;
|
|
124
127
|
var INTROSPECT_CACHE_MAX_ENTRIES = 500;
|
|
125
128
|
var INTROSPECT_TIMEOUT_MS = 5e3;
|
|
129
|
+
var warnedIntrospectClientAuthFailure = false;
|
|
130
|
+
var warnedIntrospectRateLimited = false;
|
|
126
131
|
async function introspectCacheKey(token, clientId) {
|
|
127
132
|
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
|
|
128
133
|
const hex = Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
@@ -173,11 +178,25 @@ async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret)
|
|
|
173
178
|
signal: AbortSignal.timeout(INTROSPECT_TIMEOUT_MS)
|
|
174
179
|
});
|
|
175
180
|
if (!res.ok) {
|
|
181
|
+
if (res.status === 429) {
|
|
182
|
+
if (!warnedIntrospectRateLimited) {
|
|
183
|
+
warnedIntrospectRateLimited = true;
|
|
184
|
+
console.warn(
|
|
185
|
+
"[SSO SDK] introspection \u88AB\u9650\u6D41\uFF08HTTP 429\uFF09\uFF1A\u8BF7\u4E3A\u5B50\u7AD9 BFF \u914D\u7F6E\u72EC\u7ACB\u7684 client \u7EA7\u914D\u989D\uFF0C\u6216\u964D\u4F4E middleware introspection \u9891\u7387\u3002\u672C\u6B21\u6309 unreachable \u5904\u7406\uFF08\u9ED8\u8BA4 fail-open\uFF09\u3002"
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
return "unreachable";
|
|
189
|
+
}
|
|
176
190
|
if (res.status !== 401 && res.status !== 403) {
|
|
177
191
|
return "unreachable";
|
|
178
192
|
}
|
|
179
|
-
|
|
180
|
-
|
|
193
|
+
if (!warnedIntrospectClientAuthFailure) {
|
|
194
|
+
warnedIntrospectClientAuthFailure = true;
|
|
195
|
+
console.warn(
|
|
196
|
+
`[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`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
return "unreachable";
|
|
181
200
|
}
|
|
182
201
|
const data = await res.json();
|
|
183
202
|
const active = data.active === true;
|
|
@@ -220,7 +239,7 @@ function createSsoMiddleware(config) {
|
|
|
220
239
|
}
|
|
221
240
|
return async function ssoMiddleware(request) {
|
|
222
241
|
const { pathname } = request.nextUrl;
|
|
223
|
-
if (pathname.startsWith("/_next/") || pathname.startsWith("/favicon.ico") || pathname.match(/\.(ico|png|jpg|jpeg|svg|css|js|woff2?)$/)) {
|
|
242
|
+
if (!pathname.startsWith("/api/") && (pathname.startsWith("/_next/") || pathname.startsWith("/favicon.ico") || pathname.match(/\.(ico|png|jpg|jpeg|svg|css|js|woff2?)$/))) {
|
|
224
243
|
return import_server.NextResponse.next();
|
|
225
244
|
}
|
|
226
245
|
if (pathname === callbackPath) {
|
|
@@ -409,7 +428,19 @@ async function fetchJwks(baseUrl, options = {}) {
|
|
|
409
428
|
return cachedJwks.jwks;
|
|
410
429
|
}
|
|
411
430
|
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
412
|
-
|
|
431
|
+
let jwksUri = `${baseUrl}/api/oauth/jwks`;
|
|
432
|
+
if (discovery?.jwks_uri) {
|
|
433
|
+
try {
|
|
434
|
+
if (new URL(discovery.jwks_uri).origin === new URL(baseUrl).origin) {
|
|
435
|
+
jwksUri = discovery.jwks_uri;
|
|
436
|
+
} else {
|
|
437
|
+
console.warn(
|
|
438
|
+
"[SSO SDK] Discovery \u7684 jwks_uri \u4E0E ssoBaseUrl \u4E0D\u540C\u6E90\uFF0C\u5DF2\u5FFD\u7565\u5E76\u4F7F\u7528\u9ED8\u8BA4 JWKS \u7AEF\u70B9"
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
} catch {
|
|
442
|
+
}
|
|
443
|
+
}
|
|
413
444
|
try {
|
|
414
445
|
const res = await fetch(jwksUri, {
|
|
415
446
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
@@ -539,7 +570,10 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
|
|
|
539
570
|
if (Date.now() >= payload.exp * 1e3 + 6e4) {
|
|
540
571
|
throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
|
|
541
572
|
}
|
|
542
|
-
if (typeof payload.iat
|
|
573
|
+
if (typeof payload.iat !== "number") {
|
|
574
|
+
throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 iat \u58F0\u660E");
|
|
575
|
+
}
|
|
576
|
+
if (payload.iat * 1e3 > Date.now() + 6e4) {
|
|
543
577
|
throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
|
|
544
578
|
}
|
|
545
579
|
if (typeof payload.sub !== "string" || !payload.sub) {
|
|
@@ -556,10 +590,27 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
|
|
|
556
590
|
if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
|
|
557
591
|
throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
|
|
558
592
|
}
|
|
593
|
+
} else if (typeof payload.nonce === "string" && payload.nonce) {
|
|
594
|
+
throw new SsoError(
|
|
595
|
+
"id_token_nonce_mismatch",
|
|
596
|
+
"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"
|
|
597
|
+
);
|
|
559
598
|
}
|
|
560
599
|
return { sub: payload.sub };
|
|
561
600
|
}
|
|
562
601
|
|
|
602
|
+
// src/core/http.ts
|
|
603
|
+
var REQUEST_TIMEOUT_MS = 1e4;
|
|
604
|
+
async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
605
|
+
const controller = new AbortController();
|
|
606
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
607
|
+
try {
|
|
608
|
+
return await fetch(input, { ...init, signal: controller.signal });
|
|
609
|
+
} finally {
|
|
610
|
+
clearTimeout(timer);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
563
614
|
// src/next/callback.ts
|
|
564
615
|
function escapeHtml(value) {
|
|
565
616
|
return value.replace(/[&<>"']/g, (ch) => {
|
|
@@ -660,8 +711,14 @@ function createCallbackRouteHandler(config) {
|
|
|
660
711
|
const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
|
|
661
712
|
const secureCookies = !insecureLocalDev;
|
|
662
713
|
const pickName = (explicit, fallback) => insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : explicit ?? fallback;
|
|
663
|
-
const accessTokenCookieName = pickName(
|
|
664
|
-
|
|
714
|
+
const accessTokenCookieName = pickName(
|
|
715
|
+
config.accessTokenCookieName,
|
|
716
|
+
DEFAULT_ACCESS_TOKEN_COOKIE_NAME
|
|
717
|
+
);
|
|
718
|
+
const refreshTokenCookieName = pickName(
|
|
719
|
+
config.refreshTokenCookieName,
|
|
720
|
+
DEFAULT_REFRESH_TOKEN_COOKIE_NAME
|
|
721
|
+
);
|
|
665
722
|
const idTokenCookieName = pickName(config.idTokenCookieName, DEFAULT_ID_TOKEN_COOKIE_NAME);
|
|
666
723
|
const stateCookieName = pickName(config.stateCookieName, DEFAULT_STATE_COOKIE_NAME);
|
|
667
724
|
const nonceCookieName = pickName(config.nonceCookieName, DEFAULT_NONCE_COOKIE_NAME);
|
|
@@ -695,13 +752,7 @@ function createCallbackRouteHandler(config) {
|
|
|
695
752
|
const error = searchParams.get("error");
|
|
696
753
|
if (error) {
|
|
697
754
|
const desc = searchParams.get("error_description") || error;
|
|
698
|
-
return buildErrorResponse(
|
|
699
|
-
request,
|
|
700
|
-
400,
|
|
701
|
-
"authorization_failed",
|
|
702
|
-
desc,
|
|
703
|
-
config.renderErrorPage
|
|
704
|
-
);
|
|
755
|
+
return buildErrorResponse(request, 400, "authorization_failed", desc, config.renderErrorPage);
|
|
705
756
|
}
|
|
706
757
|
const code = searchParams.get("code");
|
|
707
758
|
const returnedState = searchParams.get("state");
|
|
@@ -755,6 +806,20 @@ function createCallbackRouteHandler(config) {
|
|
|
755
806
|
config.renderErrorPage
|
|
756
807
|
);
|
|
757
808
|
}
|
|
809
|
+
const returnedIss = searchParams.get("iss");
|
|
810
|
+
if (returnedIss) {
|
|
811
|
+
const discovery = await fetchDiscoveryDoc(normalizedBase);
|
|
812
|
+
const expectedIssuer = (discovery?.issuer || normalizedBase).replace(/\/+$/, "");
|
|
813
|
+
if (returnedIss.replace(/\/+$/, "") !== expectedIssuer) {
|
|
814
|
+
return buildErrorResponse(
|
|
815
|
+
request,
|
|
816
|
+
400,
|
|
817
|
+
"issuer_mismatch",
|
|
818
|
+
"\u6388\u6743\u54CD\u5E94 issuer \u4E0D\u5339\u914D\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u767B\u5F55",
|
|
819
|
+
config.renderErrorPage
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
758
823
|
const expectedNonce = readTransientCookie(nonceCookieName);
|
|
759
824
|
const verifier = readTransientCookie(verifierCookieName);
|
|
760
825
|
if (!verifier) {
|
|
@@ -778,31 +843,18 @@ function createCallbackRouteHandler(config) {
|
|
|
778
843
|
}
|
|
779
844
|
let res = null;
|
|
780
845
|
let lastError;
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
}
|
|
786
|
-
try {
|
|
787
|
-
res = await fetch(tokenEndpoint, {
|
|
846
|
+
try {
|
|
847
|
+
res = await fetchWithTimeout(
|
|
848
|
+
tokenEndpoint,
|
|
849
|
+
{
|
|
788
850
|
method: "POST",
|
|
789
851
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
790
852
|
body: body.toString()
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
if (attempt >= maxRetries) {
|
|
797
|
-
return buildErrorResponse(
|
|
798
|
-
request,
|
|
799
|
-
502,
|
|
800
|
-
"server_error",
|
|
801
|
-
"\u767B\u5F55\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF08Token \u8BF7\u6C42\u5931\u8D25\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
|
|
802
|
-
config.renderErrorPage
|
|
803
|
-
);
|
|
804
|
-
}
|
|
805
|
-
}
|
|
853
|
+
},
|
|
854
|
+
1e4
|
|
855
|
+
);
|
|
856
|
+
} catch (err) {
|
|
857
|
+
lastError = err;
|
|
806
858
|
}
|
|
807
859
|
if (lastError || !res) {
|
|
808
860
|
return buildErrorResponse(
|
|
@@ -913,8 +965,169 @@ function createCallbackRouteHandler(config) {
|
|
|
913
965
|
}
|
|
914
966
|
}
|
|
915
967
|
|
|
916
|
-
// src/next/
|
|
968
|
+
// src/next/refresh.ts
|
|
917
969
|
var import_server3 = require("next/server");
|
|
970
|
+
function noStoreJson(body, status = 200) {
|
|
971
|
+
return import_server3.NextResponse.json(body, {
|
|
972
|
+
status,
|
|
973
|
+
headers: { "Cache-Control": "no-store", Pragma: "no-cache" }
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
function clearAuthCookies(response, names, secureCookies) {
|
|
977
|
+
response.cookies.set(names.access, "", getHostCookieOptions(0, secureCookies));
|
|
978
|
+
response.cookies.set(names.refresh, "", getHostCookieOptions(0, secureCookies));
|
|
979
|
+
response.cookies.set(names.idToken, "", getHostCookieOptions(0, secureCookies));
|
|
980
|
+
return response;
|
|
981
|
+
}
|
|
982
|
+
function createRefreshRouteHandler(config) {
|
|
983
|
+
const {
|
|
984
|
+
clientId,
|
|
985
|
+
ssoBaseUrl,
|
|
986
|
+
redirectUri,
|
|
987
|
+
clientSecret,
|
|
988
|
+
insecureLocalDev: insecureLocalDevOpt = false,
|
|
989
|
+
debug = false
|
|
990
|
+
} = config;
|
|
991
|
+
const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
|
|
992
|
+
const secureCookies = !insecureLocalDev;
|
|
993
|
+
const pickName = (explicit, fallback) => insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : explicit ?? fallback;
|
|
994
|
+
const accessTokenCookieName = pickName(
|
|
995
|
+
config.accessTokenCookieName,
|
|
996
|
+
DEFAULT_ACCESS_TOKEN_COOKIE_NAME
|
|
997
|
+
);
|
|
998
|
+
const refreshTokenCookieName = pickName(
|
|
999
|
+
config.refreshTokenCookieName,
|
|
1000
|
+
DEFAULT_REFRESH_TOKEN_COOKIE_NAME
|
|
1001
|
+
);
|
|
1002
|
+
const idTokenCookieName = pickName(config.idTokenCookieName, DEFAULT_ID_TOKEN_COOKIE_NAME);
|
|
1003
|
+
const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
|
|
1004
|
+
const normalizedServerBase = (config.serverBaseUrl ?? ssoBaseUrl).replace(/\/+$/, "");
|
|
1005
|
+
const callbackOrigin = new URL(redirectUri).origin;
|
|
1006
|
+
const cookieNames = {
|
|
1007
|
+
access: accessTokenCookieName,
|
|
1008
|
+
refresh: refreshTokenCookieName,
|
|
1009
|
+
idToken: idTokenCookieName
|
|
1010
|
+
};
|
|
1011
|
+
return async function POST(request) {
|
|
1012
|
+
const requestOrigin = request.headers.get("origin");
|
|
1013
|
+
if (requestOrigin && requestOrigin !== callbackOrigin) {
|
|
1014
|
+
return noStoreJson({ error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" }, 403);
|
|
1015
|
+
}
|
|
1016
|
+
const secFetchSite = request.headers.get("sec-fetch-site");
|
|
1017
|
+
if (!requestOrigin && secFetchSite && secFetchSite !== "same-origin" && secFetchSite !== "none") {
|
|
1018
|
+
return noStoreJson({ error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" }, 403);
|
|
1019
|
+
}
|
|
1020
|
+
const refreshToken = request.cookies.get(refreshTokenCookieName)?.value;
|
|
1021
|
+
if (!refreshToken) {
|
|
1022
|
+
return noStoreJson({ error: "no_refresh_token", error_description: "\u7F3A\u5C11 refresh_token" }, 401);
|
|
1023
|
+
}
|
|
1024
|
+
const body = new URLSearchParams({
|
|
1025
|
+
grant_type: "refresh_token",
|
|
1026
|
+
refresh_token: refreshToken,
|
|
1027
|
+
client_id: clientId
|
|
1028
|
+
});
|
|
1029
|
+
if (clientSecret) body.set("client_secret", clientSecret);
|
|
1030
|
+
let res = null;
|
|
1031
|
+
let lastError;
|
|
1032
|
+
try {
|
|
1033
|
+
res = await fetchWithTimeout(
|
|
1034
|
+
`${normalizedServerBase}/api/oauth/token`,
|
|
1035
|
+
{
|
|
1036
|
+
method: "POST",
|
|
1037
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1038
|
+
body: body.toString()
|
|
1039
|
+
},
|
|
1040
|
+
1e4
|
|
1041
|
+
);
|
|
1042
|
+
} catch (err) {
|
|
1043
|
+
lastError = err;
|
|
1044
|
+
}
|
|
1045
|
+
if (lastError || !res) {
|
|
1046
|
+
if (debug) console.warn("[SSO SDK] refresh: token \u8BF7\u6C42\u5931\u8D25", lastError);
|
|
1047
|
+
return noStoreJson(
|
|
1048
|
+
{ error: "server_error", error_description: "\u5237\u65B0\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528" },
|
|
1049
|
+
502
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
let data = {};
|
|
1053
|
+
try {
|
|
1054
|
+
data = await res.json();
|
|
1055
|
+
} catch {
|
|
1056
|
+
}
|
|
1057
|
+
if (!res.ok) {
|
|
1058
|
+
const error = typeof data.error === "string" ? data.error : "";
|
|
1059
|
+
const status = res.status === 429 ? 429 : res.status >= 500 ? 502 : 401;
|
|
1060
|
+
const response2 = noStoreJson(
|
|
1061
|
+
{
|
|
1062
|
+
error: error || "refresh_failed",
|
|
1063
|
+
error_description: data.error_description || "\u5237\u65B0\u767B\u5F55\u6001\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55"
|
|
1064
|
+
},
|
|
1065
|
+
status
|
|
1066
|
+
);
|
|
1067
|
+
if (res.status === 400 || res.status === 401) {
|
|
1068
|
+
return clearAuthCookies(response2, cookieNames, secureCookies);
|
|
1069
|
+
}
|
|
1070
|
+
return response2;
|
|
1071
|
+
}
|
|
1072
|
+
if (!data.access_token || typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0) {
|
|
1073
|
+
return clearAuthCookies(
|
|
1074
|
+
noStoreJson({ error: "server_error", error_description: "\u5237\u65B0\u54CD\u5E94\u4E0D\u5B8C\u6574" }, 502),
|
|
1075
|
+
cookieNames,
|
|
1076
|
+
secureCookies
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
if (data.id_token) {
|
|
1080
|
+
try {
|
|
1081
|
+
await validateIdToken(
|
|
1082
|
+
data.id_token,
|
|
1083
|
+
data.access_token,
|
|
1084
|
+
normalizedBase,
|
|
1085
|
+
clientId
|
|
1086
|
+
);
|
|
1087
|
+
} catch (err) {
|
|
1088
|
+
if (debug) console.warn("[SSO SDK] refresh: ID Token \u6821\u9A8C\u5931\u8D25", err);
|
|
1089
|
+
return clearAuthCookies(
|
|
1090
|
+
noStoreJson({ error: "id_token_invalid", error_description: "\u767B\u5F55\u6821\u9A8C\u5931\u8D25" }, 401),
|
|
1091
|
+
cookieNames,
|
|
1092
|
+
secureCookies
|
|
1093
|
+
);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
const response = noStoreJson({ success: true, expires_in: data.expires_in });
|
|
1097
|
+
response.cookies.set(accessTokenCookieName, data.access_token, {
|
|
1098
|
+
...getHostCookieOptions(data.expires_in, secureCookies)
|
|
1099
|
+
});
|
|
1100
|
+
response.cookies.set(refreshTokenCookieName, data.refresh_token || refreshToken, {
|
|
1101
|
+
...getHostCookieOptions(
|
|
1102
|
+
typeof data.refresh_expires_in === "number" && data.refresh_expires_in > 0 ? data.refresh_expires_in : 30 * 24 * 60 * 60,
|
|
1103
|
+
secureCookies
|
|
1104
|
+
)
|
|
1105
|
+
});
|
|
1106
|
+
if (data.id_token) {
|
|
1107
|
+
let idTokenMaxAge = 3600;
|
|
1108
|
+
try {
|
|
1109
|
+
const payload = JSON.parse(
|
|
1110
|
+
Buffer.from(data.id_token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString(
|
|
1111
|
+
"utf8"
|
|
1112
|
+
)
|
|
1113
|
+
);
|
|
1114
|
+
if (typeof payload.exp === "number") {
|
|
1115
|
+
idTokenMaxAge = payload.exp - Math.floor(Date.now() / 1e3);
|
|
1116
|
+
}
|
|
1117
|
+
} catch {
|
|
1118
|
+
}
|
|
1119
|
+
if (idTokenMaxAge > 0) {
|
|
1120
|
+
response.cookies.set(idTokenCookieName, data.id_token, {
|
|
1121
|
+
...getHostCookieOptions(idTokenMaxAge, secureCookies)
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
return response;
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// src/next/logout.ts
|
|
1130
|
+
var import_server4 = require("next/server");
|
|
918
1131
|
|
|
919
1132
|
// src/core/discovery.ts
|
|
920
1133
|
var DISCOVERY_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
@@ -985,6 +1198,14 @@ function generateRandomString2(length) {
|
|
|
985
1198
|
}
|
|
986
1199
|
return result;
|
|
987
1200
|
}
|
|
1201
|
+
function trustedSameOriginEndpoint(endpoint, baseUrl) {
|
|
1202
|
+
if (!endpoint) return null;
|
|
1203
|
+
try {
|
|
1204
|
+
return new URL(endpoint).origin === new URL(baseUrl).origin ? endpoint : null;
|
|
1205
|
+
} catch {
|
|
1206
|
+
return null;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
988
1209
|
function createLogoutRouteHandler(config) {
|
|
989
1210
|
const {
|
|
990
1211
|
clientId,
|
|
@@ -1022,32 +1243,32 @@ function createLogoutRouteHandler(config) {
|
|
|
1022
1243
|
const returnedState = request.nextUrl.searchParams.get("state");
|
|
1023
1244
|
if (returnedState) {
|
|
1024
1245
|
const savedState = request.cookies.get(logoutStateCookieName)?.value;
|
|
1025
|
-
if (!savedState || savedState
|
|
1026
|
-
return
|
|
1246
|
+
if (!savedState || !timingSafeEqualString(savedState, returnedState)) {
|
|
1247
|
+
return import_server4.NextResponse.json(
|
|
1027
1248
|
{ error: "invalid_request", error_description: "Logout state \u4E0D\u5339\u914D" },
|
|
1028
1249
|
{ status: 400 }
|
|
1029
1250
|
);
|
|
1030
1251
|
}
|
|
1031
|
-
const res =
|
|
1252
|
+
const res = import_server4.NextResponse.redirect(callbackOrigin + "/");
|
|
1032
1253
|
res.cookies.set(logoutStateCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
1033
1254
|
return res;
|
|
1034
1255
|
}
|
|
1035
1256
|
if (request.method === "GET") {
|
|
1036
|
-
return new
|
|
1257
|
+
return new import_server4.NextResponse(buildLogoutConfirmHtml(), {
|
|
1037
1258
|
status: 200,
|
|
1038
1259
|
headers: { "content-type": "text/html; charset=utf-8" }
|
|
1039
1260
|
});
|
|
1040
1261
|
}
|
|
1041
1262
|
const requestOrigin = request.headers.get("origin");
|
|
1042
1263
|
if (requestOrigin && requestOrigin !== callbackOrigin) {
|
|
1043
|
-
return
|
|
1264
|
+
return import_server4.NextResponse.json(
|
|
1044
1265
|
{ error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" },
|
|
1045
1266
|
{ status: 403 }
|
|
1046
1267
|
);
|
|
1047
1268
|
}
|
|
1048
1269
|
const secFetchSite = request.headers.get("sec-fetch-site");
|
|
1049
1270
|
if (!requestOrigin && secFetchSite && secFetchSite !== "same-origin" && secFetchSite !== "none") {
|
|
1050
|
-
return
|
|
1271
|
+
return import_server4.NextResponse.json(
|
|
1051
1272
|
{ error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" },
|
|
1052
1273
|
{ status: 403 }
|
|
1053
1274
|
);
|
|
@@ -1063,7 +1284,7 @@ function createLogoutRouteHandler(config) {
|
|
|
1063
1284
|
if (refreshToken) {
|
|
1064
1285
|
try {
|
|
1065
1286
|
const discovery = config.serverBaseUrl ? null : await fetchDiscoveryCached(normalizedServerBase);
|
|
1066
|
-
const revokeUrl = config.serverBaseUrl ? `${normalizedServerBase}/api/oauth/revoke` : discovery?.revocation_endpoint || `${normalizedBase}/api/oauth/revoke`;
|
|
1287
|
+
const revokeUrl = (config.serverBaseUrl ? `${normalizedServerBase}/api/oauth/revoke` : trustedSameOriginEndpoint(discovery?.revocation_endpoint, normalizedBase)) || `${normalizedBase}/api/oauth/revoke`;
|
|
1067
1288
|
const body = new URLSearchParams({
|
|
1068
1289
|
token: refreshToken,
|
|
1069
1290
|
token_type_hint: "refresh_token",
|
|
@@ -1072,12 +1293,20 @@ function createLogoutRouteHandler(config) {
|
|
|
1072
1293
|
if (clientSecret) {
|
|
1073
1294
|
body.set("client_secret", clientSecret);
|
|
1074
1295
|
}
|
|
1075
|
-
|
|
1296
|
+
const doRevoke = () => fetch(revokeUrl, {
|
|
1076
1297
|
method: "POST",
|
|
1077
1298
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1078
1299
|
body: body.toString(),
|
|
1079
1300
|
signal: AbortSignal.timeout(3e3)
|
|
1080
1301
|
});
|
|
1302
|
+
let revokeRes = await doRevoke();
|
|
1303
|
+
if (!revokeRes.ok && (revokeRes.status >= 500 || revokeRes.status === 429)) {
|
|
1304
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
1305
|
+
revokeRes = await doRevoke();
|
|
1306
|
+
}
|
|
1307
|
+
if (!revokeRes.ok) {
|
|
1308
|
+
console.warn(`[SSO SDK] \u767B\u51FA\u64A4\u9500 refresh_token \u5931\u8D25\uFF08HTTP ${revokeRes.status}\uFF09`);
|
|
1309
|
+
}
|
|
1081
1310
|
} catch {
|
|
1082
1311
|
}
|
|
1083
1312
|
}
|
|
@@ -1109,7 +1338,7 @@ function createLogoutRouteHandler(config) {
|
|
|
1109
1338
|
};
|
|
1110
1339
|
if (effectiveScope === "global") {
|
|
1111
1340
|
const discovery = await fetchDiscoveryCached(normalizedServerBase);
|
|
1112
|
-
const endSessionEndpoint = discovery?.end_session_endpoint || `${normalizedBase}/api/oauth/end-session`;
|
|
1341
|
+
const endSessionEndpoint = trustedSameOriginEndpoint(discovery?.end_session_endpoint, normalizedBase) || `${normalizedBase}/api/oauth/end-session`;
|
|
1113
1342
|
const logoutUrl = new URL(endSessionEndpoint);
|
|
1114
1343
|
logoutUrl.searchParams.set("client_id", clientId);
|
|
1115
1344
|
logoutUrl.searchParams.set(
|
|
@@ -1121,25 +1350,36 @@ function createLogoutRouteHandler(config) {
|
|
|
1121
1350
|
}
|
|
1122
1351
|
const logoutState = generateRandomString2(32);
|
|
1123
1352
|
logoutUrl.searchParams.set("state", logoutState);
|
|
1124
|
-
const res = clearCookies(
|
|
1353
|
+
const res = clearCookies(import_server4.NextResponse.redirect(logoutUrl.toString()));
|
|
1125
1354
|
res.cookies.set(logoutStateCookieName, logoutState, getHostCookieOptions(600, secureCookies));
|
|
1126
1355
|
return res;
|
|
1127
1356
|
}
|
|
1128
1357
|
return clearCookies(
|
|
1129
|
-
|
|
1358
|
+
import_server4.NextResponse.redirect(callbackOrigin + "/")
|
|
1130
1359
|
);
|
|
1131
1360
|
};
|
|
1132
1361
|
}
|
|
1133
1362
|
|
|
1134
1363
|
// src/next/backchannel-logout.ts
|
|
1135
|
-
var
|
|
1364
|
+
var import_server5 = require("next/server");
|
|
1136
1365
|
|
|
1137
1366
|
// src/core/logout-token.ts
|
|
1138
1367
|
var BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
|
|
1139
1368
|
var JTI_CACHE_CAPACITY = 1e3;
|
|
1140
1369
|
var seenJti = /* @__PURE__ */ new Map();
|
|
1141
|
-
function releaseLogoutTokenJti(
|
|
1142
|
-
|
|
1370
|
+
async function releaseLogoutTokenJti(replayKey, options = {}) {
|
|
1371
|
+
const store = options.jtiStore;
|
|
1372
|
+
if (store?.remove) {
|
|
1373
|
+
await store.remove(replayKey);
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
if (store) {
|
|
1377
|
+
console.warn(
|
|
1378
|
+
"[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"
|
|
1379
|
+
);
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
seenJti.delete(replayKey);
|
|
1143
1383
|
}
|
|
1144
1384
|
function recordJti(jti, expiresAtMs) {
|
|
1145
1385
|
const now = Date.now();
|
|
@@ -1156,7 +1396,7 @@ function recordJti(jti, expiresAtMs) {
|
|
|
1156
1396
|
seenJti.set(jti, expiresAtMs);
|
|
1157
1397
|
return true;
|
|
1158
1398
|
}
|
|
1159
|
-
async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId) {
|
|
1399
|
+
async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId, options = {}) {
|
|
1160
1400
|
const baseUrl = ssoBaseUrl.replace(/\/+$/, "");
|
|
1161
1401
|
const header = decodeJwtHeader(logoutToken);
|
|
1162
1402
|
if (!header) {
|
|
@@ -1233,6 +1473,12 @@ async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId) {
|
|
|
1233
1473
|
if (Date.now() >= payload.exp * 1e3 + 6e4) {
|
|
1234
1474
|
throw new SsoError("logout_token_expired", "Logout Token \u5DF2\u8FC7\u671F");
|
|
1235
1475
|
}
|
|
1476
|
+
if (typeof payload.iat !== "number") {
|
|
1477
|
+
throw new SsoError("logout_token_invalid", "Logout Token \u7F3A\u5C11 iat \u58F0\u660E");
|
|
1478
|
+
}
|
|
1479
|
+
if (payload.nonce !== void 0) {
|
|
1480
|
+
throw new SsoError("logout_token_invalid", "Logout Token \u4E0D\u5F97\u643A\u5E26 nonce");
|
|
1481
|
+
}
|
|
1236
1482
|
if (payload.type !== "logout_token") {
|
|
1237
1483
|
throw new SsoError(
|
|
1238
1484
|
"logout_token_invalid",
|
|
@@ -1265,10 +1511,27 @@ async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId) {
|
|
|
1265
1511
|
if (!jti) {
|
|
1266
1512
|
throw new SsoError("logout_token_invalid", "Logout Token \u7F3A\u5C11 jti \u58F0\u660E");
|
|
1267
1513
|
}
|
|
1268
|
-
|
|
1514
|
+
const replayKey = `${normalizedIssuer}:${jti}`;
|
|
1515
|
+
const replayTtlSeconds = Math.max(
|
|
1516
|
+
1,
|
|
1517
|
+
Math.ceil((payload.exp * 1e3 + 6e4 - Date.now()) / 1e3)
|
|
1518
|
+
);
|
|
1519
|
+
const jtiStore = options.jtiStore;
|
|
1520
|
+
if (jtiStore) {
|
|
1521
|
+
if (jtiStore.addIfAbsent) {
|
|
1522
|
+
if (!await jtiStore.addIfAbsent(replayKey, replayTtlSeconds)) {
|
|
1523
|
+
throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
|
|
1524
|
+
}
|
|
1525
|
+
} else {
|
|
1526
|
+
if (await jtiStore.has(replayKey)) {
|
|
1527
|
+
throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
|
|
1528
|
+
}
|
|
1529
|
+
await jtiStore.add(replayKey, replayTtlSeconds);
|
|
1530
|
+
}
|
|
1531
|
+
} else if (!recordJti(replayKey, payload.exp * 1e3 + 6e4)) {
|
|
1269
1532
|
throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
|
|
1270
1533
|
}
|
|
1271
|
-
return { payload: { sub, sid }, jti };
|
|
1534
|
+
return { payload: { sub, sid }, jti, replayKey };
|
|
1272
1535
|
}
|
|
1273
1536
|
|
|
1274
1537
|
// src/next/backchannel-logout.ts
|
|
@@ -1294,7 +1557,7 @@ function createBackchannelLogoutRouteHandler(config) {
|
|
|
1294
1557
|
);
|
|
1295
1558
|
return async function handler(request) {
|
|
1296
1559
|
if (request.method !== "POST") {
|
|
1297
|
-
return
|
|
1560
|
+
return import_server5.NextResponse.json(
|
|
1298
1561
|
{ error: "method_not_allowed", error_description: "\u4EC5\u63A5\u53D7 POST \u8BF7\u6C42" },
|
|
1299
1562
|
{ status: 405 }
|
|
1300
1563
|
);
|
|
@@ -1303,18 +1566,20 @@ function createBackchannelLogoutRouteHandler(config) {
|
|
|
1303
1566
|
"logout_token"
|
|
1304
1567
|
);
|
|
1305
1568
|
if (!logoutToken) {
|
|
1306
|
-
return
|
|
1569
|
+
return import_server5.NextResponse.json(
|
|
1307
1570
|
{ error: "invalid_request", error_description: "\u7F3A\u5C11 logout_token" },
|
|
1308
1571
|
{ status: 400 }
|
|
1309
1572
|
);
|
|
1310
1573
|
}
|
|
1311
1574
|
let verified;
|
|
1312
1575
|
try {
|
|
1313
|
-
verified = await verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId
|
|
1576
|
+
verified = await verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId, {
|
|
1577
|
+
jtiStore: config.jtiStore
|
|
1578
|
+
});
|
|
1314
1579
|
} catch (err) {
|
|
1315
1580
|
const code = err instanceof SsoError ? err.code : "logout_token_invalid";
|
|
1316
1581
|
const description = err instanceof SsoError ? err.description : "Logout Token \u9A8C\u8BC1\u5931\u8D25";
|
|
1317
|
-
return
|
|
1582
|
+
return import_server5.NextResponse.json(
|
|
1318
1583
|
{ error: code, error_description: description },
|
|
1319
1584
|
{ status: 400 }
|
|
1320
1585
|
);
|
|
@@ -1322,17 +1587,17 @@ function createBackchannelLogoutRouteHandler(config) {
|
|
|
1322
1587
|
try {
|
|
1323
1588
|
await onLogout?.(verified.payload, request);
|
|
1324
1589
|
} catch (err) {
|
|
1325
|
-
releaseLogoutTokenJti(verified.
|
|
1590
|
+
await releaseLogoutTokenJti(verified.replayKey, { jtiStore: config.jtiStore });
|
|
1326
1591
|
console.error(
|
|
1327
1592
|
"[SSO SDK] backchannel logout onLogout \u94A9\u5B50\u6267\u884C\u5931\u8D25:",
|
|
1328
1593
|
err
|
|
1329
1594
|
);
|
|
1330
|
-
return
|
|
1595
|
+
return import_server5.NextResponse.json(
|
|
1331
1596
|
{ error: "server_error", error_description: "onLogout \u5904\u7406\u5931\u8D25" },
|
|
1332
1597
|
{ status: 500 }
|
|
1333
1598
|
);
|
|
1334
1599
|
}
|
|
1335
|
-
const res = new
|
|
1600
|
+
const res = new import_server5.NextResponse(null, { status: 200 });
|
|
1336
1601
|
res.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
1337
1602
|
res.cookies.set(refreshTokenCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
1338
1603
|
res.cookies.set(idTokenCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
@@ -1352,6 +1617,7 @@ function createBackchannelLogoutRouteHandler(config) {
|
|
|
1352
1617
|
createBackchannelLogoutRouteHandler,
|
|
1353
1618
|
createCallbackRouteHandler,
|
|
1354
1619
|
createLogoutRouteHandler,
|
|
1620
|
+
createRefreshRouteHandler,
|
|
1355
1621
|
createSsoMiddleware,
|
|
1356
1622
|
getHostCookieOptions,
|
|
1357
1623
|
getSecureCookieOptions,
|