@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.mjs
CHANGED
|
@@ -74,15 +74,19 @@ 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;
|
|
89
|
+
var warnedIntrospectRateLimited = false;
|
|
86
90
|
async function introspectCacheKey(token, clientId) {
|
|
87
91
|
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
|
|
88
92
|
const hex = Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
@@ -133,11 +137,25 @@ async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret)
|
|
|
133
137
|
signal: AbortSignal.timeout(INTROSPECT_TIMEOUT_MS)
|
|
134
138
|
});
|
|
135
139
|
if (!res.ok) {
|
|
140
|
+
if (res.status === 429) {
|
|
141
|
+
if (!warnedIntrospectRateLimited) {
|
|
142
|
+
warnedIntrospectRateLimited = true;
|
|
143
|
+
console.warn(
|
|
144
|
+
"[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"
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
return "unreachable";
|
|
148
|
+
}
|
|
136
149
|
if (res.status !== 401 && res.status !== 403) {
|
|
137
150
|
return "unreachable";
|
|
138
151
|
}
|
|
139
|
-
|
|
140
|
-
|
|
152
|
+
if (!warnedIntrospectClientAuthFailure) {
|
|
153
|
+
warnedIntrospectClientAuthFailure = true;
|
|
154
|
+
console.warn(
|
|
155
|
+
`[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`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return "unreachable";
|
|
141
159
|
}
|
|
142
160
|
const data = await res.json();
|
|
143
161
|
const active = data.active === true;
|
|
@@ -180,7 +198,7 @@ function createSsoMiddleware(config) {
|
|
|
180
198
|
}
|
|
181
199
|
return async function ssoMiddleware(request) {
|
|
182
200
|
const { pathname } = request.nextUrl;
|
|
183
|
-
if (pathname.startsWith("/_next/") || pathname.startsWith("/favicon.ico") || pathname.match(/\.(ico|png|jpg|jpeg|svg|css|js|woff2?)$/)) {
|
|
201
|
+
if (!pathname.startsWith("/api/") && (pathname.startsWith("/_next/") || pathname.startsWith("/favicon.ico") || pathname.match(/\.(ico|png|jpg|jpeg|svg|css|js|woff2?)$/))) {
|
|
184
202
|
return NextResponse.next();
|
|
185
203
|
}
|
|
186
204
|
if (pathname === callbackPath) {
|
|
@@ -369,7 +387,19 @@ async function fetchJwks(baseUrl, options = {}) {
|
|
|
369
387
|
return cachedJwks.jwks;
|
|
370
388
|
}
|
|
371
389
|
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
372
|
-
|
|
390
|
+
let jwksUri = `${baseUrl}/api/oauth/jwks`;
|
|
391
|
+
if (discovery?.jwks_uri) {
|
|
392
|
+
try {
|
|
393
|
+
if (new URL(discovery.jwks_uri).origin === new URL(baseUrl).origin) {
|
|
394
|
+
jwksUri = discovery.jwks_uri;
|
|
395
|
+
} else {
|
|
396
|
+
console.warn(
|
|
397
|
+
"[SSO SDK] Discovery \u7684 jwks_uri \u4E0E ssoBaseUrl \u4E0D\u540C\u6E90\uFF0C\u5DF2\u5FFD\u7565\u5E76\u4F7F\u7528\u9ED8\u8BA4 JWKS \u7AEF\u70B9"
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
} catch {
|
|
401
|
+
}
|
|
402
|
+
}
|
|
373
403
|
try {
|
|
374
404
|
const res = await fetch(jwksUri, {
|
|
375
405
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
@@ -499,7 +529,10 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
|
|
|
499
529
|
if (Date.now() >= payload.exp * 1e3 + 6e4) {
|
|
500
530
|
throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
|
|
501
531
|
}
|
|
502
|
-
if (typeof payload.iat
|
|
532
|
+
if (typeof payload.iat !== "number") {
|
|
533
|
+
throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 iat \u58F0\u660E");
|
|
534
|
+
}
|
|
535
|
+
if (payload.iat * 1e3 > Date.now() + 6e4) {
|
|
503
536
|
throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
|
|
504
537
|
}
|
|
505
538
|
if (typeof payload.sub !== "string" || !payload.sub) {
|
|
@@ -516,10 +549,27 @@ async function validateIdToken(idToken, accessToken, expectedIssuer, expectedCli
|
|
|
516
549
|
if (!tokenNonce || !timingSafeEqualString(expectedNonce, tokenNonce)) {
|
|
517
550
|
throw new SsoError("id_token_nonce_mismatch", "ID Token nonce \u4E0D\u5339\u914D");
|
|
518
551
|
}
|
|
552
|
+
} else if (typeof payload.nonce === "string" && payload.nonce) {
|
|
553
|
+
throw new SsoError(
|
|
554
|
+
"id_token_nonce_mismatch",
|
|
555
|
+
"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"
|
|
556
|
+
);
|
|
519
557
|
}
|
|
520
558
|
return { sub: payload.sub };
|
|
521
559
|
}
|
|
522
560
|
|
|
561
|
+
// src/core/http.ts
|
|
562
|
+
var REQUEST_TIMEOUT_MS = 1e4;
|
|
563
|
+
async function fetchWithTimeout(input, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
564
|
+
const controller = new AbortController();
|
|
565
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
566
|
+
try {
|
|
567
|
+
return await fetch(input, { ...init, signal: controller.signal });
|
|
568
|
+
} finally {
|
|
569
|
+
clearTimeout(timer);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
523
573
|
// src/next/callback.ts
|
|
524
574
|
function escapeHtml(value) {
|
|
525
575
|
return value.replace(/[&<>"']/g, (ch) => {
|
|
@@ -620,8 +670,14 @@ function createCallbackRouteHandler(config) {
|
|
|
620
670
|
const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
|
|
621
671
|
const secureCookies = !insecureLocalDev;
|
|
622
672
|
const pickName = (explicit, fallback) => insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : explicit ?? fallback;
|
|
623
|
-
const accessTokenCookieName = pickName(
|
|
624
|
-
|
|
673
|
+
const accessTokenCookieName = pickName(
|
|
674
|
+
config.accessTokenCookieName,
|
|
675
|
+
DEFAULT_ACCESS_TOKEN_COOKIE_NAME
|
|
676
|
+
);
|
|
677
|
+
const refreshTokenCookieName = pickName(
|
|
678
|
+
config.refreshTokenCookieName,
|
|
679
|
+
DEFAULT_REFRESH_TOKEN_COOKIE_NAME
|
|
680
|
+
);
|
|
625
681
|
const idTokenCookieName = pickName(config.idTokenCookieName, DEFAULT_ID_TOKEN_COOKIE_NAME);
|
|
626
682
|
const stateCookieName = pickName(config.stateCookieName, DEFAULT_STATE_COOKIE_NAME);
|
|
627
683
|
const nonceCookieName = pickName(config.nonceCookieName, DEFAULT_NONCE_COOKIE_NAME);
|
|
@@ -655,13 +711,7 @@ function createCallbackRouteHandler(config) {
|
|
|
655
711
|
const error = searchParams.get("error");
|
|
656
712
|
if (error) {
|
|
657
713
|
const desc = searchParams.get("error_description") || error;
|
|
658
|
-
return buildErrorResponse(
|
|
659
|
-
request,
|
|
660
|
-
400,
|
|
661
|
-
"authorization_failed",
|
|
662
|
-
desc,
|
|
663
|
-
config.renderErrorPage
|
|
664
|
-
);
|
|
714
|
+
return buildErrorResponse(request, 400, "authorization_failed", desc, config.renderErrorPage);
|
|
665
715
|
}
|
|
666
716
|
const code = searchParams.get("code");
|
|
667
717
|
const returnedState = searchParams.get("state");
|
|
@@ -715,6 +765,20 @@ function createCallbackRouteHandler(config) {
|
|
|
715
765
|
config.renderErrorPage
|
|
716
766
|
);
|
|
717
767
|
}
|
|
768
|
+
const returnedIss = searchParams.get("iss");
|
|
769
|
+
if (returnedIss) {
|
|
770
|
+
const discovery = await fetchDiscoveryDoc(normalizedBase);
|
|
771
|
+
const expectedIssuer = (discovery?.issuer || normalizedBase).replace(/\/+$/, "");
|
|
772
|
+
if (returnedIss.replace(/\/+$/, "") !== expectedIssuer) {
|
|
773
|
+
return buildErrorResponse(
|
|
774
|
+
request,
|
|
775
|
+
400,
|
|
776
|
+
"issuer_mismatch",
|
|
777
|
+
"\u6388\u6743\u54CD\u5E94 issuer \u4E0D\u5339\u914D\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u767B\u5F55",
|
|
778
|
+
config.renderErrorPage
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
718
782
|
const expectedNonce = readTransientCookie(nonceCookieName);
|
|
719
783
|
const verifier = readTransientCookie(verifierCookieName);
|
|
720
784
|
if (!verifier) {
|
|
@@ -738,31 +802,18 @@ function createCallbackRouteHandler(config) {
|
|
|
738
802
|
}
|
|
739
803
|
let res = null;
|
|
740
804
|
let lastError;
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
}
|
|
746
|
-
try {
|
|
747
|
-
res = await fetch(tokenEndpoint, {
|
|
805
|
+
try {
|
|
806
|
+
res = await fetchWithTimeout(
|
|
807
|
+
tokenEndpoint,
|
|
808
|
+
{
|
|
748
809
|
method: "POST",
|
|
749
810
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
750
811
|
body: body.toString()
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
if (attempt >= maxRetries) {
|
|
757
|
-
return buildErrorResponse(
|
|
758
|
-
request,
|
|
759
|
-
502,
|
|
760
|
-
"server_error",
|
|
761
|
-
"\u767B\u5F55\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF08Token \u8BF7\u6C42\u5931\u8D25\uFF09\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
|
|
762
|
-
config.renderErrorPage
|
|
763
|
-
);
|
|
764
|
-
}
|
|
765
|
-
}
|
|
812
|
+
},
|
|
813
|
+
1e4
|
|
814
|
+
);
|
|
815
|
+
} catch (err) {
|
|
816
|
+
lastError = err;
|
|
766
817
|
}
|
|
767
818
|
if (lastError || !res) {
|
|
768
819
|
return buildErrorResponse(
|
|
@@ -873,8 +924,169 @@ function createCallbackRouteHandler(config) {
|
|
|
873
924
|
}
|
|
874
925
|
}
|
|
875
926
|
|
|
876
|
-
// src/next/
|
|
927
|
+
// src/next/refresh.ts
|
|
877
928
|
import { NextResponse as NextResponse3 } from "next/server";
|
|
929
|
+
function noStoreJson(body, status = 200) {
|
|
930
|
+
return NextResponse3.json(body, {
|
|
931
|
+
status,
|
|
932
|
+
headers: { "Cache-Control": "no-store", Pragma: "no-cache" }
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
function clearAuthCookies(response, names, secureCookies) {
|
|
936
|
+
response.cookies.set(names.access, "", getHostCookieOptions(0, secureCookies));
|
|
937
|
+
response.cookies.set(names.refresh, "", getHostCookieOptions(0, secureCookies));
|
|
938
|
+
response.cookies.set(names.idToken, "", getHostCookieOptions(0, secureCookies));
|
|
939
|
+
return response;
|
|
940
|
+
}
|
|
941
|
+
function createRefreshRouteHandler(config) {
|
|
942
|
+
const {
|
|
943
|
+
clientId,
|
|
944
|
+
ssoBaseUrl,
|
|
945
|
+
redirectUri,
|
|
946
|
+
clientSecret,
|
|
947
|
+
insecureLocalDev: insecureLocalDevOpt = false,
|
|
948
|
+
debug = false
|
|
949
|
+
} = config;
|
|
950
|
+
const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
|
|
951
|
+
const secureCookies = !insecureLocalDev;
|
|
952
|
+
const pickName = (explicit, fallback) => insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : explicit ?? fallback;
|
|
953
|
+
const accessTokenCookieName = pickName(
|
|
954
|
+
config.accessTokenCookieName,
|
|
955
|
+
DEFAULT_ACCESS_TOKEN_COOKIE_NAME
|
|
956
|
+
);
|
|
957
|
+
const refreshTokenCookieName = pickName(
|
|
958
|
+
config.refreshTokenCookieName,
|
|
959
|
+
DEFAULT_REFRESH_TOKEN_COOKIE_NAME
|
|
960
|
+
);
|
|
961
|
+
const idTokenCookieName = pickName(config.idTokenCookieName, DEFAULT_ID_TOKEN_COOKIE_NAME);
|
|
962
|
+
const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
|
|
963
|
+
const normalizedServerBase = (config.serverBaseUrl ?? ssoBaseUrl).replace(/\/+$/, "");
|
|
964
|
+
const callbackOrigin = new URL(redirectUri).origin;
|
|
965
|
+
const cookieNames = {
|
|
966
|
+
access: accessTokenCookieName,
|
|
967
|
+
refresh: refreshTokenCookieName,
|
|
968
|
+
idToken: idTokenCookieName
|
|
969
|
+
};
|
|
970
|
+
return async function POST(request) {
|
|
971
|
+
const requestOrigin = request.headers.get("origin");
|
|
972
|
+
if (requestOrigin && requestOrigin !== callbackOrigin) {
|
|
973
|
+
return noStoreJson({ error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" }, 403);
|
|
974
|
+
}
|
|
975
|
+
const secFetchSite = request.headers.get("sec-fetch-site");
|
|
976
|
+
if (!requestOrigin && secFetchSite && secFetchSite !== "same-origin" && secFetchSite !== "none") {
|
|
977
|
+
return noStoreJson({ error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" }, 403);
|
|
978
|
+
}
|
|
979
|
+
const refreshToken = request.cookies.get(refreshTokenCookieName)?.value;
|
|
980
|
+
if (!refreshToken) {
|
|
981
|
+
return noStoreJson({ error: "no_refresh_token", error_description: "\u7F3A\u5C11 refresh_token" }, 401);
|
|
982
|
+
}
|
|
983
|
+
const body = new URLSearchParams({
|
|
984
|
+
grant_type: "refresh_token",
|
|
985
|
+
refresh_token: refreshToken,
|
|
986
|
+
client_id: clientId
|
|
987
|
+
});
|
|
988
|
+
if (clientSecret) body.set("client_secret", clientSecret);
|
|
989
|
+
let res = null;
|
|
990
|
+
let lastError;
|
|
991
|
+
try {
|
|
992
|
+
res = await fetchWithTimeout(
|
|
993
|
+
`${normalizedServerBase}/api/oauth/token`,
|
|
994
|
+
{
|
|
995
|
+
method: "POST",
|
|
996
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
997
|
+
body: body.toString()
|
|
998
|
+
},
|
|
999
|
+
1e4
|
|
1000
|
+
);
|
|
1001
|
+
} catch (err) {
|
|
1002
|
+
lastError = err;
|
|
1003
|
+
}
|
|
1004
|
+
if (lastError || !res) {
|
|
1005
|
+
if (debug) console.warn("[SSO SDK] refresh: token \u8BF7\u6C42\u5931\u8D25", lastError);
|
|
1006
|
+
return noStoreJson(
|
|
1007
|
+
{ error: "server_error", error_description: "\u5237\u65B0\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528" },
|
|
1008
|
+
502
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
let data = {};
|
|
1012
|
+
try {
|
|
1013
|
+
data = await res.json();
|
|
1014
|
+
} catch {
|
|
1015
|
+
}
|
|
1016
|
+
if (!res.ok) {
|
|
1017
|
+
const error = typeof data.error === "string" ? data.error : "";
|
|
1018
|
+
const status = res.status === 429 ? 429 : res.status >= 500 ? 502 : 401;
|
|
1019
|
+
const response2 = noStoreJson(
|
|
1020
|
+
{
|
|
1021
|
+
error: error || "refresh_failed",
|
|
1022
|
+
error_description: data.error_description || "\u5237\u65B0\u767B\u5F55\u6001\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55"
|
|
1023
|
+
},
|
|
1024
|
+
status
|
|
1025
|
+
);
|
|
1026
|
+
if (res.status === 400 || res.status === 401) {
|
|
1027
|
+
return clearAuthCookies(response2, cookieNames, secureCookies);
|
|
1028
|
+
}
|
|
1029
|
+
return response2;
|
|
1030
|
+
}
|
|
1031
|
+
if (!data.access_token || typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0) {
|
|
1032
|
+
return clearAuthCookies(
|
|
1033
|
+
noStoreJson({ error: "server_error", error_description: "\u5237\u65B0\u54CD\u5E94\u4E0D\u5B8C\u6574" }, 502),
|
|
1034
|
+
cookieNames,
|
|
1035
|
+
secureCookies
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
if (data.id_token) {
|
|
1039
|
+
try {
|
|
1040
|
+
await validateIdToken(
|
|
1041
|
+
data.id_token,
|
|
1042
|
+
data.access_token,
|
|
1043
|
+
normalizedBase,
|
|
1044
|
+
clientId
|
|
1045
|
+
);
|
|
1046
|
+
} catch (err) {
|
|
1047
|
+
if (debug) console.warn("[SSO SDK] refresh: ID Token \u6821\u9A8C\u5931\u8D25", err);
|
|
1048
|
+
return clearAuthCookies(
|
|
1049
|
+
noStoreJson({ error: "id_token_invalid", error_description: "\u767B\u5F55\u6821\u9A8C\u5931\u8D25" }, 401),
|
|
1050
|
+
cookieNames,
|
|
1051
|
+
secureCookies
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
const response = noStoreJson({ success: true, expires_in: data.expires_in });
|
|
1056
|
+
response.cookies.set(accessTokenCookieName, data.access_token, {
|
|
1057
|
+
...getHostCookieOptions(data.expires_in, secureCookies)
|
|
1058
|
+
});
|
|
1059
|
+
response.cookies.set(refreshTokenCookieName, data.refresh_token || refreshToken, {
|
|
1060
|
+
...getHostCookieOptions(
|
|
1061
|
+
typeof data.refresh_expires_in === "number" && data.refresh_expires_in > 0 ? data.refresh_expires_in : 30 * 24 * 60 * 60,
|
|
1062
|
+
secureCookies
|
|
1063
|
+
)
|
|
1064
|
+
});
|
|
1065
|
+
if (data.id_token) {
|
|
1066
|
+
let idTokenMaxAge = 3600;
|
|
1067
|
+
try {
|
|
1068
|
+
const payload = JSON.parse(
|
|
1069
|
+
Buffer.from(data.id_token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString(
|
|
1070
|
+
"utf8"
|
|
1071
|
+
)
|
|
1072
|
+
);
|
|
1073
|
+
if (typeof payload.exp === "number") {
|
|
1074
|
+
idTokenMaxAge = payload.exp - Math.floor(Date.now() / 1e3);
|
|
1075
|
+
}
|
|
1076
|
+
} catch {
|
|
1077
|
+
}
|
|
1078
|
+
if (idTokenMaxAge > 0) {
|
|
1079
|
+
response.cookies.set(idTokenCookieName, data.id_token, {
|
|
1080
|
+
...getHostCookieOptions(idTokenMaxAge, secureCookies)
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
return response;
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// src/next/logout.ts
|
|
1089
|
+
import { NextResponse as NextResponse4 } from "next/server";
|
|
878
1090
|
|
|
879
1091
|
// src/core/discovery.ts
|
|
880
1092
|
var DISCOVERY_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
@@ -945,6 +1157,14 @@ function generateRandomString2(length) {
|
|
|
945
1157
|
}
|
|
946
1158
|
return result;
|
|
947
1159
|
}
|
|
1160
|
+
function trustedSameOriginEndpoint(endpoint, baseUrl) {
|
|
1161
|
+
if (!endpoint) return null;
|
|
1162
|
+
try {
|
|
1163
|
+
return new URL(endpoint).origin === new URL(baseUrl).origin ? endpoint : null;
|
|
1164
|
+
} catch {
|
|
1165
|
+
return null;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
948
1168
|
function createLogoutRouteHandler(config) {
|
|
949
1169
|
const {
|
|
950
1170
|
clientId,
|
|
@@ -982,32 +1202,32 @@ function createLogoutRouteHandler(config) {
|
|
|
982
1202
|
const returnedState = request.nextUrl.searchParams.get("state");
|
|
983
1203
|
if (returnedState) {
|
|
984
1204
|
const savedState = request.cookies.get(logoutStateCookieName)?.value;
|
|
985
|
-
if (!savedState || savedState
|
|
986
|
-
return
|
|
1205
|
+
if (!savedState || !timingSafeEqualString(savedState, returnedState)) {
|
|
1206
|
+
return NextResponse4.json(
|
|
987
1207
|
{ error: "invalid_request", error_description: "Logout state \u4E0D\u5339\u914D" },
|
|
988
1208
|
{ status: 400 }
|
|
989
1209
|
);
|
|
990
1210
|
}
|
|
991
|
-
const res =
|
|
1211
|
+
const res = NextResponse4.redirect(callbackOrigin + "/");
|
|
992
1212
|
res.cookies.set(logoutStateCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
993
1213
|
return res;
|
|
994
1214
|
}
|
|
995
1215
|
if (request.method === "GET") {
|
|
996
|
-
return new
|
|
1216
|
+
return new NextResponse4(buildLogoutConfirmHtml(), {
|
|
997
1217
|
status: 200,
|
|
998
1218
|
headers: { "content-type": "text/html; charset=utf-8" }
|
|
999
1219
|
});
|
|
1000
1220
|
}
|
|
1001
1221
|
const requestOrigin = request.headers.get("origin");
|
|
1002
1222
|
if (requestOrigin && requestOrigin !== callbackOrigin) {
|
|
1003
|
-
return
|
|
1223
|
+
return NextResponse4.json(
|
|
1004
1224
|
{ error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" },
|
|
1005
1225
|
{ status: 403 }
|
|
1006
1226
|
);
|
|
1007
1227
|
}
|
|
1008
1228
|
const secFetchSite = request.headers.get("sec-fetch-site");
|
|
1009
1229
|
if (!requestOrigin && secFetchSite && secFetchSite !== "same-origin" && secFetchSite !== "none") {
|
|
1010
|
-
return
|
|
1230
|
+
return NextResponse4.json(
|
|
1011
1231
|
{ error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" },
|
|
1012
1232
|
{ status: 403 }
|
|
1013
1233
|
);
|
|
@@ -1023,7 +1243,7 @@ function createLogoutRouteHandler(config) {
|
|
|
1023
1243
|
if (refreshToken) {
|
|
1024
1244
|
try {
|
|
1025
1245
|
const discovery = config.serverBaseUrl ? null : await fetchDiscoveryCached(normalizedServerBase);
|
|
1026
|
-
const revokeUrl = config.serverBaseUrl ? `${normalizedServerBase}/api/oauth/revoke` : discovery?.revocation_endpoint || `${normalizedBase}/api/oauth/revoke`;
|
|
1246
|
+
const revokeUrl = (config.serverBaseUrl ? `${normalizedServerBase}/api/oauth/revoke` : trustedSameOriginEndpoint(discovery?.revocation_endpoint, normalizedBase)) || `${normalizedBase}/api/oauth/revoke`;
|
|
1027
1247
|
const body = new URLSearchParams({
|
|
1028
1248
|
token: refreshToken,
|
|
1029
1249
|
token_type_hint: "refresh_token",
|
|
@@ -1032,12 +1252,20 @@ function createLogoutRouteHandler(config) {
|
|
|
1032
1252
|
if (clientSecret) {
|
|
1033
1253
|
body.set("client_secret", clientSecret);
|
|
1034
1254
|
}
|
|
1035
|
-
|
|
1255
|
+
const doRevoke = () => fetch(revokeUrl, {
|
|
1036
1256
|
method: "POST",
|
|
1037
1257
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1038
1258
|
body: body.toString(),
|
|
1039
1259
|
signal: AbortSignal.timeout(3e3)
|
|
1040
1260
|
});
|
|
1261
|
+
let revokeRes = await doRevoke();
|
|
1262
|
+
if (!revokeRes.ok && (revokeRes.status >= 500 || revokeRes.status === 429)) {
|
|
1263
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
1264
|
+
revokeRes = await doRevoke();
|
|
1265
|
+
}
|
|
1266
|
+
if (!revokeRes.ok) {
|
|
1267
|
+
console.warn(`[SSO SDK] \u767B\u51FA\u64A4\u9500 refresh_token \u5931\u8D25\uFF08HTTP ${revokeRes.status}\uFF09`);
|
|
1268
|
+
}
|
|
1041
1269
|
} catch {
|
|
1042
1270
|
}
|
|
1043
1271
|
}
|
|
@@ -1069,7 +1297,7 @@ function createLogoutRouteHandler(config) {
|
|
|
1069
1297
|
};
|
|
1070
1298
|
if (effectiveScope === "global") {
|
|
1071
1299
|
const discovery = await fetchDiscoveryCached(normalizedServerBase);
|
|
1072
|
-
const endSessionEndpoint = discovery?.end_session_endpoint || `${normalizedBase}/api/oauth/end-session`;
|
|
1300
|
+
const endSessionEndpoint = trustedSameOriginEndpoint(discovery?.end_session_endpoint, normalizedBase) || `${normalizedBase}/api/oauth/end-session`;
|
|
1073
1301
|
const logoutUrl = new URL(endSessionEndpoint);
|
|
1074
1302
|
logoutUrl.searchParams.set("client_id", clientId);
|
|
1075
1303
|
logoutUrl.searchParams.set(
|
|
@@ -1081,25 +1309,36 @@ function createLogoutRouteHandler(config) {
|
|
|
1081
1309
|
}
|
|
1082
1310
|
const logoutState = generateRandomString2(32);
|
|
1083
1311
|
logoutUrl.searchParams.set("state", logoutState);
|
|
1084
|
-
const res = clearCookies(
|
|
1312
|
+
const res = clearCookies(NextResponse4.redirect(logoutUrl.toString()));
|
|
1085
1313
|
res.cookies.set(logoutStateCookieName, logoutState, getHostCookieOptions(600, secureCookies));
|
|
1086
1314
|
return res;
|
|
1087
1315
|
}
|
|
1088
1316
|
return clearCookies(
|
|
1089
|
-
|
|
1317
|
+
NextResponse4.redirect(callbackOrigin + "/")
|
|
1090
1318
|
);
|
|
1091
1319
|
};
|
|
1092
1320
|
}
|
|
1093
1321
|
|
|
1094
1322
|
// src/next/backchannel-logout.ts
|
|
1095
|
-
import { NextResponse as
|
|
1323
|
+
import { NextResponse as NextResponse5 } from "next/server";
|
|
1096
1324
|
|
|
1097
1325
|
// src/core/logout-token.ts
|
|
1098
1326
|
var BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
|
|
1099
1327
|
var JTI_CACHE_CAPACITY = 1e3;
|
|
1100
1328
|
var seenJti = /* @__PURE__ */ new Map();
|
|
1101
|
-
function releaseLogoutTokenJti(
|
|
1102
|
-
|
|
1329
|
+
async function releaseLogoutTokenJti(replayKey, options = {}) {
|
|
1330
|
+
const store = options.jtiStore;
|
|
1331
|
+
if (store?.remove) {
|
|
1332
|
+
await store.remove(replayKey);
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
if (store) {
|
|
1336
|
+
console.warn(
|
|
1337
|
+
"[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"
|
|
1338
|
+
);
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
seenJti.delete(replayKey);
|
|
1103
1342
|
}
|
|
1104
1343
|
function recordJti(jti, expiresAtMs) {
|
|
1105
1344
|
const now = Date.now();
|
|
@@ -1116,7 +1355,7 @@ function recordJti(jti, expiresAtMs) {
|
|
|
1116
1355
|
seenJti.set(jti, expiresAtMs);
|
|
1117
1356
|
return true;
|
|
1118
1357
|
}
|
|
1119
|
-
async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId) {
|
|
1358
|
+
async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId, options = {}) {
|
|
1120
1359
|
const baseUrl = ssoBaseUrl.replace(/\/+$/, "");
|
|
1121
1360
|
const header = decodeJwtHeader(logoutToken);
|
|
1122
1361
|
if (!header) {
|
|
@@ -1193,6 +1432,12 @@ async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId) {
|
|
|
1193
1432
|
if (Date.now() >= payload.exp * 1e3 + 6e4) {
|
|
1194
1433
|
throw new SsoError("logout_token_expired", "Logout Token \u5DF2\u8FC7\u671F");
|
|
1195
1434
|
}
|
|
1435
|
+
if (typeof payload.iat !== "number") {
|
|
1436
|
+
throw new SsoError("logout_token_invalid", "Logout Token \u7F3A\u5C11 iat \u58F0\u660E");
|
|
1437
|
+
}
|
|
1438
|
+
if (payload.nonce !== void 0) {
|
|
1439
|
+
throw new SsoError("logout_token_invalid", "Logout Token \u4E0D\u5F97\u643A\u5E26 nonce");
|
|
1440
|
+
}
|
|
1196
1441
|
if (payload.type !== "logout_token") {
|
|
1197
1442
|
throw new SsoError(
|
|
1198
1443
|
"logout_token_invalid",
|
|
@@ -1225,10 +1470,27 @@ async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId) {
|
|
|
1225
1470
|
if (!jti) {
|
|
1226
1471
|
throw new SsoError("logout_token_invalid", "Logout Token \u7F3A\u5C11 jti \u58F0\u660E");
|
|
1227
1472
|
}
|
|
1228
|
-
|
|
1473
|
+
const replayKey = `${normalizedIssuer}:${jti}`;
|
|
1474
|
+
const replayTtlSeconds = Math.max(
|
|
1475
|
+
1,
|
|
1476
|
+
Math.ceil((payload.exp * 1e3 + 6e4 - Date.now()) / 1e3)
|
|
1477
|
+
);
|
|
1478
|
+
const jtiStore = options.jtiStore;
|
|
1479
|
+
if (jtiStore) {
|
|
1480
|
+
if (jtiStore.addIfAbsent) {
|
|
1481
|
+
if (!await jtiStore.addIfAbsent(replayKey, replayTtlSeconds)) {
|
|
1482
|
+
throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
|
|
1483
|
+
}
|
|
1484
|
+
} else {
|
|
1485
|
+
if (await jtiStore.has(replayKey)) {
|
|
1486
|
+
throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
|
|
1487
|
+
}
|
|
1488
|
+
await jtiStore.add(replayKey, replayTtlSeconds);
|
|
1489
|
+
}
|
|
1490
|
+
} else if (!recordJti(replayKey, payload.exp * 1e3 + 6e4)) {
|
|
1229
1491
|
throw new SsoError("logout_token_replay", "Logout Token jti \u91CD\u653E");
|
|
1230
1492
|
}
|
|
1231
|
-
return { payload: { sub, sid }, jti };
|
|
1493
|
+
return { payload: { sub, sid }, jti, replayKey };
|
|
1232
1494
|
}
|
|
1233
1495
|
|
|
1234
1496
|
// src/next/backchannel-logout.ts
|
|
@@ -1254,7 +1516,7 @@ function createBackchannelLogoutRouteHandler(config) {
|
|
|
1254
1516
|
);
|
|
1255
1517
|
return async function handler(request) {
|
|
1256
1518
|
if (request.method !== "POST") {
|
|
1257
|
-
return
|
|
1519
|
+
return NextResponse5.json(
|
|
1258
1520
|
{ error: "method_not_allowed", error_description: "\u4EC5\u63A5\u53D7 POST \u8BF7\u6C42" },
|
|
1259
1521
|
{ status: 405 }
|
|
1260
1522
|
);
|
|
@@ -1263,18 +1525,20 @@ function createBackchannelLogoutRouteHandler(config) {
|
|
|
1263
1525
|
"logout_token"
|
|
1264
1526
|
);
|
|
1265
1527
|
if (!logoutToken) {
|
|
1266
|
-
return
|
|
1528
|
+
return NextResponse5.json(
|
|
1267
1529
|
{ error: "invalid_request", error_description: "\u7F3A\u5C11 logout_token" },
|
|
1268
1530
|
{ status: 400 }
|
|
1269
1531
|
);
|
|
1270
1532
|
}
|
|
1271
1533
|
let verified;
|
|
1272
1534
|
try {
|
|
1273
|
-
verified = await verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId
|
|
1535
|
+
verified = await verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId, {
|
|
1536
|
+
jtiStore: config.jtiStore
|
|
1537
|
+
});
|
|
1274
1538
|
} catch (err) {
|
|
1275
1539
|
const code = err instanceof SsoError ? err.code : "logout_token_invalid";
|
|
1276
1540
|
const description = err instanceof SsoError ? err.description : "Logout Token \u9A8C\u8BC1\u5931\u8D25";
|
|
1277
|
-
return
|
|
1541
|
+
return NextResponse5.json(
|
|
1278
1542
|
{ error: code, error_description: description },
|
|
1279
1543
|
{ status: 400 }
|
|
1280
1544
|
);
|
|
@@ -1282,17 +1546,17 @@ function createBackchannelLogoutRouteHandler(config) {
|
|
|
1282
1546
|
try {
|
|
1283
1547
|
await onLogout?.(verified.payload, request);
|
|
1284
1548
|
} catch (err) {
|
|
1285
|
-
releaseLogoutTokenJti(verified.
|
|
1549
|
+
await releaseLogoutTokenJti(verified.replayKey, { jtiStore: config.jtiStore });
|
|
1286
1550
|
console.error(
|
|
1287
1551
|
"[SSO SDK] backchannel logout onLogout \u94A9\u5B50\u6267\u884C\u5931\u8D25:",
|
|
1288
1552
|
err
|
|
1289
1553
|
);
|
|
1290
|
-
return
|
|
1554
|
+
return NextResponse5.json(
|
|
1291
1555
|
{ error: "server_error", error_description: "onLogout \u5904\u7406\u5931\u8D25" },
|
|
1292
1556
|
{ status: 500 }
|
|
1293
1557
|
);
|
|
1294
1558
|
}
|
|
1295
|
-
const res = new
|
|
1559
|
+
const res = new NextResponse5(null, { status: 200 });
|
|
1296
1560
|
res.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
1297
1561
|
res.cookies.set(refreshTokenCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
1298
1562
|
res.cookies.set(idTokenCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
@@ -1311,6 +1575,7 @@ export {
|
|
|
1311
1575
|
createBackchannelLogoutRouteHandler,
|
|
1312
1576
|
createCallbackRouteHandler,
|
|
1313
1577
|
createLogoutRouteHandler,
|
|
1578
|
+
createRefreshRouteHandler,
|
|
1314
1579
|
createSsoMiddleware,
|
|
1315
1580
|
getHostCookieOptions,
|
|
1316
1581
|
getSecureCookieOptions,
|