@nihplod/sso-sdk 1.6.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/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +69 -78
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +69 -78
- package/dist/index.mjs.map +1 -1
- package/dist/next/index.d.mts +50 -1
- package/dist/next/index.d.ts +50 -1
- package/dist/next/index.js +260 -56
- package/dist/next/index.js.map +1 -1
- package/dist/next/index.mjs +259 -56
- package/dist/next/index.mjs.map +1 -1
- package/dist/react/index.d.mts +7 -2
- package/dist/react/index.d.ts +7 -2
- package/dist/react/index.js +106 -96
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +106 -96
- package/dist/react/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/next/index.d.mts
CHANGED
|
@@ -208,6 +208,55 @@ interface CallbackRouteConfig {
|
|
|
208
208
|
}
|
|
209
209
|
declare function createCallbackRouteHandler(config: CallbackRouteConfig): (request: NextRequest) => Promise<NextResponse<unknown>>;
|
|
210
210
|
|
|
211
|
+
/**
|
|
212
|
+
* App Router Refresh Route Handler
|
|
213
|
+
*
|
|
214
|
+
* 使用 httpOnly refresh cookie 在服务端换取新的 access/refresh/id token,
|
|
215
|
+
* 并更新 Cookie。适用于 Next.js BFF/Confidential Client:token 不暴露给浏览器 JS。
|
|
216
|
+
*
|
|
217
|
+
* 用法 (src/app/api/auth/refresh/route.ts):
|
|
218
|
+
* ```ts
|
|
219
|
+
* import { createRefreshRouteHandler } from "@nihplod/sso-sdk/next";
|
|
220
|
+
*
|
|
221
|
+
* export const POST = createRefreshRouteHandler({
|
|
222
|
+
* clientId: process.env.SSO_CLIENT_ID!,
|
|
223
|
+
* clientSecret: process.env.SSO_CLIENT_SECRET,
|
|
224
|
+
* ssoBaseUrl: "https://nihplod.cn",
|
|
225
|
+
* redirectUri: "https://myapp.com/api/auth/callback",
|
|
226
|
+
* });
|
|
227
|
+
* ```
|
|
228
|
+
*/
|
|
229
|
+
|
|
230
|
+
interface RefreshRouteConfig {
|
|
231
|
+
/** OAuth Client ID */
|
|
232
|
+
clientId: string;
|
|
233
|
+
/** SSO 中心地址 */
|
|
234
|
+
ssoBaseUrl: string;
|
|
235
|
+
/** 回调 URL(用于同源校验,必须与注册的 redirect_uri 一致) */
|
|
236
|
+
redirectUri: string;
|
|
237
|
+
/** OAuth Client Secret(Confidential Client 必填) */
|
|
238
|
+
clientSecret?: string;
|
|
239
|
+
/** Access Token Cookie 名称,默认 __Host-nihplod_sso_at */
|
|
240
|
+
accessTokenCookieName?: string;
|
|
241
|
+
/** Refresh Token Cookie 名称,默认 __Host-nihplod_sso_rt */
|
|
242
|
+
refreshTokenCookieName?: string;
|
|
243
|
+
/** ID Token Cookie 名称,默认 __Host-nihplod_sso_id */
|
|
244
|
+
idTokenCookieName?: string;
|
|
245
|
+
/**
|
|
246
|
+
* 本地 HTTP 开发模式(默认 false)。必须与 middleware / callback / logout 配置一致。
|
|
247
|
+
* 生产环境(NODE_ENV=production 且 ssoBaseUrl 为 https)下会被强制忽略并告警。
|
|
248
|
+
*/
|
|
249
|
+
insecureLocalDev?: boolean;
|
|
250
|
+
/**
|
|
251
|
+
* 服务端到服务端调用的内网地址(可选,如 http://127.0.0.1:3000)。
|
|
252
|
+
* 仅用于 token 交换;浏览器跳转仍使用 ssoBaseUrl 公网地址。
|
|
253
|
+
*/
|
|
254
|
+
serverBaseUrl?: string;
|
|
255
|
+
/** 调试日志开关(默认 false) */
|
|
256
|
+
debug?: boolean;
|
|
257
|
+
}
|
|
258
|
+
declare function createRefreshRouteHandler(config: RefreshRouteConfig): (request: NextRequest) => Promise<NextResponse>;
|
|
259
|
+
|
|
211
260
|
/**
|
|
212
261
|
* App Router 登出 Route Handler
|
|
213
262
|
*
|
|
@@ -427,4 +476,4 @@ declare function getSecureCookieOptions(maxAge?: number, path?: string, secure?:
|
|
|
427
476
|
maxAge?: number;
|
|
428
477
|
};
|
|
429
478
|
|
|
430
|
-
export { type BackchannelLogoutRouteConfig, type CallbackRouteConfig, DEFAULT_ACCESS_TOKEN_COOKIE_NAME, DEFAULT_ID_TOKEN_COOKIE_NAME, DEFAULT_LOGOUT_STATE_COOKIE_NAME, DEFAULT_NONCE_COOKIE_NAME, DEFAULT_REFRESH_TOKEN_COOKIE_NAME, DEFAULT_RETURN_COOKIE_NAME, DEFAULT_STATE_COOKIE_NAME, DEFAULT_VERIFIER_COOKIE_NAME, type LogoutJtiStore, type LogoutRouteConfig, type SsoMiddlewareConfig, createBackchannelLogoutRouteHandler, createCallbackRouteHandler, createLogoutRouteHandler, createSsoMiddleware, getHostCookieOptions, getSecureCookieOptions, toInsecureCookieName };
|
|
479
|
+
export { type BackchannelLogoutRouteConfig, type CallbackRouteConfig, DEFAULT_ACCESS_TOKEN_COOKIE_NAME, DEFAULT_ID_TOKEN_COOKIE_NAME, DEFAULT_LOGOUT_STATE_COOKIE_NAME, DEFAULT_NONCE_COOKIE_NAME, DEFAULT_REFRESH_TOKEN_COOKIE_NAME, DEFAULT_RETURN_COOKIE_NAME, DEFAULT_STATE_COOKIE_NAME, DEFAULT_VERIFIER_COOKIE_NAME, type LogoutJtiStore, type LogoutRouteConfig, type RefreshRouteConfig, type SsoMiddlewareConfig, createBackchannelLogoutRouteHandler, createCallbackRouteHandler, createLogoutRouteHandler, createRefreshRouteHandler, createSsoMiddleware, getHostCookieOptions, getSecureCookieOptions, toInsecureCookieName };
|
package/dist/next/index.d.ts
CHANGED
|
@@ -208,6 +208,55 @@ interface CallbackRouteConfig {
|
|
|
208
208
|
}
|
|
209
209
|
declare function createCallbackRouteHandler(config: CallbackRouteConfig): (request: NextRequest) => Promise<NextResponse<unknown>>;
|
|
210
210
|
|
|
211
|
+
/**
|
|
212
|
+
* App Router Refresh Route Handler
|
|
213
|
+
*
|
|
214
|
+
* 使用 httpOnly refresh cookie 在服务端换取新的 access/refresh/id token,
|
|
215
|
+
* 并更新 Cookie。适用于 Next.js BFF/Confidential Client:token 不暴露给浏览器 JS。
|
|
216
|
+
*
|
|
217
|
+
* 用法 (src/app/api/auth/refresh/route.ts):
|
|
218
|
+
* ```ts
|
|
219
|
+
* import { createRefreshRouteHandler } from "@nihplod/sso-sdk/next";
|
|
220
|
+
*
|
|
221
|
+
* export const POST = createRefreshRouteHandler({
|
|
222
|
+
* clientId: process.env.SSO_CLIENT_ID!,
|
|
223
|
+
* clientSecret: process.env.SSO_CLIENT_SECRET,
|
|
224
|
+
* ssoBaseUrl: "https://nihplod.cn",
|
|
225
|
+
* redirectUri: "https://myapp.com/api/auth/callback",
|
|
226
|
+
* });
|
|
227
|
+
* ```
|
|
228
|
+
*/
|
|
229
|
+
|
|
230
|
+
interface RefreshRouteConfig {
|
|
231
|
+
/** OAuth Client ID */
|
|
232
|
+
clientId: string;
|
|
233
|
+
/** SSO 中心地址 */
|
|
234
|
+
ssoBaseUrl: string;
|
|
235
|
+
/** 回调 URL(用于同源校验,必须与注册的 redirect_uri 一致) */
|
|
236
|
+
redirectUri: string;
|
|
237
|
+
/** OAuth Client Secret(Confidential Client 必填) */
|
|
238
|
+
clientSecret?: string;
|
|
239
|
+
/** Access Token Cookie 名称,默认 __Host-nihplod_sso_at */
|
|
240
|
+
accessTokenCookieName?: string;
|
|
241
|
+
/** Refresh Token Cookie 名称,默认 __Host-nihplod_sso_rt */
|
|
242
|
+
refreshTokenCookieName?: string;
|
|
243
|
+
/** ID Token Cookie 名称,默认 __Host-nihplod_sso_id */
|
|
244
|
+
idTokenCookieName?: string;
|
|
245
|
+
/**
|
|
246
|
+
* 本地 HTTP 开发模式(默认 false)。必须与 middleware / callback / logout 配置一致。
|
|
247
|
+
* 生产环境(NODE_ENV=production 且 ssoBaseUrl 为 https)下会被强制忽略并告警。
|
|
248
|
+
*/
|
|
249
|
+
insecureLocalDev?: boolean;
|
|
250
|
+
/**
|
|
251
|
+
* 服务端到服务端调用的内网地址(可选,如 http://127.0.0.1:3000)。
|
|
252
|
+
* 仅用于 token 交换;浏览器跳转仍使用 ssoBaseUrl 公网地址。
|
|
253
|
+
*/
|
|
254
|
+
serverBaseUrl?: string;
|
|
255
|
+
/** 调试日志开关(默认 false) */
|
|
256
|
+
debug?: boolean;
|
|
257
|
+
}
|
|
258
|
+
declare function createRefreshRouteHandler(config: RefreshRouteConfig): (request: NextRequest) => Promise<NextResponse>;
|
|
259
|
+
|
|
211
260
|
/**
|
|
212
261
|
* App Router 登出 Route Handler
|
|
213
262
|
*
|
|
@@ -427,4 +476,4 @@ declare function getSecureCookieOptions(maxAge?: number, path?: string, secure?:
|
|
|
427
476
|
maxAge?: number;
|
|
428
477
|
};
|
|
429
478
|
|
|
430
|
-
export { type BackchannelLogoutRouteConfig, type CallbackRouteConfig, DEFAULT_ACCESS_TOKEN_COOKIE_NAME, DEFAULT_ID_TOKEN_COOKIE_NAME, DEFAULT_LOGOUT_STATE_COOKIE_NAME, DEFAULT_NONCE_COOKIE_NAME, DEFAULT_REFRESH_TOKEN_COOKIE_NAME, DEFAULT_RETURN_COOKIE_NAME, DEFAULT_STATE_COOKIE_NAME, DEFAULT_VERIFIER_COOKIE_NAME, type LogoutJtiStore, type LogoutRouteConfig, type SsoMiddlewareConfig, createBackchannelLogoutRouteHandler, createCallbackRouteHandler, createLogoutRouteHandler, createSsoMiddleware, getHostCookieOptions, getSecureCookieOptions, toInsecureCookieName };
|
|
479
|
+
export { type BackchannelLogoutRouteConfig, type CallbackRouteConfig, DEFAULT_ACCESS_TOKEN_COOKIE_NAME, DEFAULT_ID_TOKEN_COOKIE_NAME, DEFAULT_LOGOUT_STATE_COOKIE_NAME, DEFAULT_NONCE_COOKIE_NAME, DEFAULT_REFRESH_TOKEN_COOKIE_NAME, DEFAULT_RETURN_COOKIE_NAME, DEFAULT_STATE_COOKIE_NAME, DEFAULT_VERIFIER_COOKIE_NAME, type LogoutJtiStore, type LogoutRouteConfig, type RefreshRouteConfig, type SsoMiddlewareConfig, createBackchannelLogoutRouteHandler, createCallbackRouteHandler, createLogoutRouteHandler, createRefreshRouteHandler, createSsoMiddleware, getHostCookieOptions, getSecureCookieOptions, toInsecureCookieName };
|
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,
|
|
@@ -126,6 +127,7 @@ var INTROSPECT_CACHE_TTL_MS = 3e4;
|
|
|
126
127
|
var INTROSPECT_CACHE_MAX_ENTRIES = 500;
|
|
127
128
|
var INTROSPECT_TIMEOUT_MS = 5e3;
|
|
128
129
|
var warnedIntrospectClientAuthFailure = false;
|
|
130
|
+
var warnedIntrospectRateLimited = false;
|
|
129
131
|
async function introspectCacheKey(token, clientId) {
|
|
130
132
|
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
|
|
131
133
|
const hex = Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
@@ -176,6 +178,15 @@ async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret)
|
|
|
176
178
|
signal: AbortSignal.timeout(INTROSPECT_TIMEOUT_MS)
|
|
177
179
|
});
|
|
178
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
|
+
}
|
|
179
190
|
if (res.status !== 401 && res.status !== 403) {
|
|
180
191
|
return "unreachable";
|
|
181
192
|
}
|
|
@@ -417,7 +428,19 @@ async function fetchJwks(baseUrl, options = {}) {
|
|
|
417
428
|
return cachedJwks.jwks;
|
|
418
429
|
}
|
|
419
430
|
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
420
|
-
|
|
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
|
+
}
|
|
421
444
|
try {
|
|
422
445
|
const res = await fetch(jwksUri, {
|
|
423
446
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
@@ -688,8 +711,14 @@ function createCallbackRouteHandler(config) {
|
|
|
688
711
|
const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
|
|
689
712
|
const secureCookies = !insecureLocalDev;
|
|
690
713
|
const pickName = (explicit, fallback) => insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : explicit ?? fallback;
|
|
691
|
-
const accessTokenCookieName = pickName(
|
|
692
|
-
|
|
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
|
+
);
|
|
693
722
|
const idTokenCookieName = pickName(config.idTokenCookieName, DEFAULT_ID_TOKEN_COOKIE_NAME);
|
|
694
723
|
const stateCookieName = pickName(config.stateCookieName, DEFAULT_STATE_COOKIE_NAME);
|
|
695
724
|
const nonceCookieName = pickName(config.nonceCookieName, DEFAULT_NONCE_COOKIE_NAME);
|
|
@@ -723,13 +752,7 @@ function createCallbackRouteHandler(config) {
|
|
|
723
752
|
const error = searchParams.get("error");
|
|
724
753
|
if (error) {
|
|
725
754
|
const desc = searchParams.get("error_description") || error;
|
|
726
|
-
return buildErrorResponse(
|
|
727
|
-
request,
|
|
728
|
-
400,
|
|
729
|
-
"authorization_failed",
|
|
730
|
-
desc,
|
|
731
|
-
config.renderErrorPage
|
|
732
|
-
);
|
|
755
|
+
return buildErrorResponse(request, 400, "authorization_failed", desc, config.renderErrorPage);
|
|
733
756
|
}
|
|
734
757
|
const code = searchParams.get("code");
|
|
735
758
|
const returnedState = searchParams.get("state");
|
|
@@ -783,6 +806,20 @@ function createCallbackRouteHandler(config) {
|
|
|
783
806
|
config.renderErrorPage
|
|
784
807
|
);
|
|
785
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
|
+
}
|
|
786
823
|
const expectedNonce = readTransientCookie(nonceCookieName);
|
|
787
824
|
const verifier = readTransientCookie(verifierCookieName);
|
|
788
825
|
if (!verifier) {
|
|
@@ -806,35 +843,18 @@ function createCallbackRouteHandler(config) {
|
|
|
806
843
|
}
|
|
807
844
|
let res = null;
|
|
808
845
|
let lastError;
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
},
|
|
822
|
-
1e4
|
|
823
|
-
);
|
|
824
|
-
lastError = null;
|
|
825
|
-
break;
|
|
826
|
-
} catch (err) {
|
|
827
|
-
lastError = err;
|
|
828
|
-
if (attempt >= maxRetries) {
|
|
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
|
|
835
|
-
);
|
|
836
|
-
}
|
|
837
|
-
}
|
|
846
|
+
try {
|
|
847
|
+
res = await fetchWithTimeout(
|
|
848
|
+
tokenEndpoint,
|
|
849
|
+
{
|
|
850
|
+
method: "POST",
|
|
851
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
852
|
+
body: body.toString()
|
|
853
|
+
},
|
|
854
|
+
1e4
|
|
855
|
+
);
|
|
856
|
+
} catch (err) {
|
|
857
|
+
lastError = err;
|
|
838
858
|
}
|
|
839
859
|
if (lastError || !res) {
|
|
840
860
|
return buildErrorResponse(
|
|
@@ -945,8 +965,169 @@ function createCallbackRouteHandler(config) {
|
|
|
945
965
|
}
|
|
946
966
|
}
|
|
947
967
|
|
|
948
|
-
// src/next/
|
|
968
|
+
// src/next/refresh.ts
|
|
949
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");
|
|
950
1131
|
|
|
951
1132
|
// src/core/discovery.ts
|
|
952
1133
|
var DISCOVERY_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
@@ -1017,6 +1198,14 @@ function generateRandomString2(length) {
|
|
|
1017
1198
|
}
|
|
1018
1199
|
return result;
|
|
1019
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
|
+
}
|
|
1020
1209
|
function createLogoutRouteHandler(config) {
|
|
1021
1210
|
const {
|
|
1022
1211
|
clientId,
|
|
@@ -1055,31 +1244,31 @@ function createLogoutRouteHandler(config) {
|
|
|
1055
1244
|
if (returnedState) {
|
|
1056
1245
|
const savedState = request.cookies.get(logoutStateCookieName)?.value;
|
|
1057
1246
|
if (!savedState || !timingSafeEqualString(savedState, returnedState)) {
|
|
1058
|
-
return
|
|
1247
|
+
return import_server4.NextResponse.json(
|
|
1059
1248
|
{ error: "invalid_request", error_description: "Logout state \u4E0D\u5339\u914D" },
|
|
1060
1249
|
{ status: 400 }
|
|
1061
1250
|
);
|
|
1062
1251
|
}
|
|
1063
|
-
const res =
|
|
1252
|
+
const res = import_server4.NextResponse.redirect(callbackOrigin + "/");
|
|
1064
1253
|
res.cookies.set(logoutStateCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
1065
1254
|
return res;
|
|
1066
1255
|
}
|
|
1067
1256
|
if (request.method === "GET") {
|
|
1068
|
-
return new
|
|
1257
|
+
return new import_server4.NextResponse(buildLogoutConfirmHtml(), {
|
|
1069
1258
|
status: 200,
|
|
1070
1259
|
headers: { "content-type": "text/html; charset=utf-8" }
|
|
1071
1260
|
});
|
|
1072
1261
|
}
|
|
1073
1262
|
const requestOrigin = request.headers.get("origin");
|
|
1074
1263
|
if (requestOrigin && requestOrigin !== callbackOrigin) {
|
|
1075
|
-
return
|
|
1264
|
+
return import_server4.NextResponse.json(
|
|
1076
1265
|
{ error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" },
|
|
1077
1266
|
{ status: 403 }
|
|
1078
1267
|
);
|
|
1079
1268
|
}
|
|
1080
1269
|
const secFetchSite = request.headers.get("sec-fetch-site");
|
|
1081
1270
|
if (!requestOrigin && secFetchSite && secFetchSite !== "same-origin" && secFetchSite !== "none") {
|
|
1082
|
-
return
|
|
1271
|
+
return import_server4.NextResponse.json(
|
|
1083
1272
|
{ error: "forbidden", error_description: "\u8DE8\u7AD9\u8BF7\u6C42\u88AB\u62D2\u7EDD" },
|
|
1084
1273
|
{ status: 403 }
|
|
1085
1274
|
);
|
|
@@ -1095,7 +1284,7 @@ function createLogoutRouteHandler(config) {
|
|
|
1095
1284
|
if (refreshToken) {
|
|
1096
1285
|
try {
|
|
1097
1286
|
const discovery = config.serverBaseUrl ? null : await fetchDiscoveryCached(normalizedServerBase);
|
|
1098
|
-
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`;
|
|
1099
1288
|
const body = new URLSearchParams({
|
|
1100
1289
|
token: refreshToken,
|
|
1101
1290
|
token_type_hint: "refresh_token",
|
|
@@ -1104,12 +1293,20 @@ function createLogoutRouteHandler(config) {
|
|
|
1104
1293
|
if (clientSecret) {
|
|
1105
1294
|
body.set("client_secret", clientSecret);
|
|
1106
1295
|
}
|
|
1107
|
-
|
|
1296
|
+
const doRevoke = () => fetch(revokeUrl, {
|
|
1108
1297
|
method: "POST",
|
|
1109
1298
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1110
1299
|
body: body.toString(),
|
|
1111
1300
|
signal: AbortSignal.timeout(3e3)
|
|
1112
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
|
+
}
|
|
1113
1310
|
} catch {
|
|
1114
1311
|
}
|
|
1115
1312
|
}
|
|
@@ -1141,7 +1338,7 @@ function createLogoutRouteHandler(config) {
|
|
|
1141
1338
|
};
|
|
1142
1339
|
if (effectiveScope === "global") {
|
|
1143
1340
|
const discovery = await fetchDiscoveryCached(normalizedServerBase);
|
|
1144
|
-
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`;
|
|
1145
1342
|
const logoutUrl = new URL(endSessionEndpoint);
|
|
1146
1343
|
logoutUrl.searchParams.set("client_id", clientId);
|
|
1147
1344
|
logoutUrl.searchParams.set(
|
|
@@ -1153,18 +1350,18 @@ function createLogoutRouteHandler(config) {
|
|
|
1153
1350
|
}
|
|
1154
1351
|
const logoutState = generateRandomString2(32);
|
|
1155
1352
|
logoutUrl.searchParams.set("state", logoutState);
|
|
1156
|
-
const res = clearCookies(
|
|
1353
|
+
const res = clearCookies(import_server4.NextResponse.redirect(logoutUrl.toString()));
|
|
1157
1354
|
res.cookies.set(logoutStateCookieName, logoutState, getHostCookieOptions(600, secureCookies));
|
|
1158
1355
|
return res;
|
|
1159
1356
|
}
|
|
1160
1357
|
return clearCookies(
|
|
1161
|
-
|
|
1358
|
+
import_server4.NextResponse.redirect(callbackOrigin + "/")
|
|
1162
1359
|
);
|
|
1163
1360
|
};
|
|
1164
1361
|
}
|
|
1165
1362
|
|
|
1166
1363
|
// src/next/backchannel-logout.ts
|
|
1167
|
-
var
|
|
1364
|
+
var import_server5 = require("next/server");
|
|
1168
1365
|
|
|
1169
1366
|
// src/core/logout-token.ts
|
|
1170
1367
|
var BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
|
|
@@ -1276,6 +1473,12 @@ async function verifyLogoutTokenDetailed(logoutToken, ssoBaseUrl, clientId, opti
|
|
|
1276
1473
|
if (Date.now() >= payload.exp * 1e3 + 6e4) {
|
|
1277
1474
|
throw new SsoError("logout_token_expired", "Logout Token \u5DF2\u8FC7\u671F");
|
|
1278
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
|
+
}
|
|
1279
1482
|
if (payload.type !== "logout_token") {
|
|
1280
1483
|
throw new SsoError(
|
|
1281
1484
|
"logout_token_invalid",
|
|
@@ -1354,7 +1557,7 @@ function createBackchannelLogoutRouteHandler(config) {
|
|
|
1354
1557
|
);
|
|
1355
1558
|
return async function handler(request) {
|
|
1356
1559
|
if (request.method !== "POST") {
|
|
1357
|
-
return
|
|
1560
|
+
return import_server5.NextResponse.json(
|
|
1358
1561
|
{ error: "method_not_allowed", error_description: "\u4EC5\u63A5\u53D7 POST \u8BF7\u6C42" },
|
|
1359
1562
|
{ status: 405 }
|
|
1360
1563
|
);
|
|
@@ -1363,7 +1566,7 @@ function createBackchannelLogoutRouteHandler(config) {
|
|
|
1363
1566
|
"logout_token"
|
|
1364
1567
|
);
|
|
1365
1568
|
if (!logoutToken) {
|
|
1366
|
-
return
|
|
1569
|
+
return import_server5.NextResponse.json(
|
|
1367
1570
|
{ error: "invalid_request", error_description: "\u7F3A\u5C11 logout_token" },
|
|
1368
1571
|
{ status: 400 }
|
|
1369
1572
|
);
|
|
@@ -1376,7 +1579,7 @@ function createBackchannelLogoutRouteHandler(config) {
|
|
|
1376
1579
|
} catch (err) {
|
|
1377
1580
|
const code = err instanceof SsoError ? err.code : "logout_token_invalid";
|
|
1378
1581
|
const description = err instanceof SsoError ? err.description : "Logout Token \u9A8C\u8BC1\u5931\u8D25";
|
|
1379
|
-
return
|
|
1582
|
+
return import_server5.NextResponse.json(
|
|
1380
1583
|
{ error: code, error_description: description },
|
|
1381
1584
|
{ status: 400 }
|
|
1382
1585
|
);
|
|
@@ -1389,12 +1592,12 @@ function createBackchannelLogoutRouteHandler(config) {
|
|
|
1389
1592
|
"[SSO SDK] backchannel logout onLogout \u94A9\u5B50\u6267\u884C\u5931\u8D25:",
|
|
1390
1593
|
err
|
|
1391
1594
|
);
|
|
1392
|
-
return
|
|
1595
|
+
return import_server5.NextResponse.json(
|
|
1393
1596
|
{ error: "server_error", error_description: "onLogout \u5904\u7406\u5931\u8D25" },
|
|
1394
1597
|
{ status: 500 }
|
|
1395
1598
|
);
|
|
1396
1599
|
}
|
|
1397
|
-
const res = new
|
|
1600
|
+
const res = new import_server5.NextResponse(null, { status: 200 });
|
|
1398
1601
|
res.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
1399
1602
|
res.cookies.set(refreshTokenCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
1400
1603
|
res.cookies.set(idTokenCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
@@ -1414,6 +1617,7 @@ function createBackchannelLogoutRouteHandler(config) {
|
|
|
1414
1617
|
createBackchannelLogoutRouteHandler,
|
|
1415
1618
|
createCallbackRouteHandler,
|
|
1416
1619
|
createLogoutRouteHandler,
|
|
1620
|
+
createRefreshRouteHandler,
|
|
1417
1621
|
createSsoMiddleware,
|
|
1418
1622
|
getHostCookieOptions,
|
|
1419
1623
|
getSecureCookieOptions,
|