@nihplod/sso-sdk 1.0.1 → 1.2.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 +92 -9
- package/dist/index.d.mts +136 -13
- package/dist/index.d.ts +136 -13
- package/dist/index.js +475 -78
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +467 -77
- package/dist/index.mjs.map +1 -1
- package/dist/next/index.d.mts +54 -6
- package/dist/next/index.d.ts +54 -6
- package/dist/next/index.js +355 -85
- package/dist/next/index.js.map +1 -1
- package/dist/next/index.mjs +351 -84
- package/dist/next/index.mjs.map +1 -1
- package/dist/react/index.d.mts +175 -14
- package/dist/react/index.d.ts +175 -14
- package/dist/react/index.js +627 -151
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +630 -155
- package/dist/react/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/next/index.mjs
CHANGED
|
@@ -8,19 +8,36 @@ var DEFAULT_ID_TOKEN_COOKIE_NAME = "__Host-nihplod_sso_id";
|
|
|
8
8
|
var DEFAULT_STATE_COOKIE_NAME = "__Host-nihplod_sso_state";
|
|
9
9
|
var DEFAULT_RETURN_COOKIE_NAME = "__Host-nihplod_sso_return";
|
|
10
10
|
var DEFAULT_VERIFIER_COOKIE_NAME = "__Secure-nihplod_sso_verifier";
|
|
11
|
-
|
|
11
|
+
var DEFAULT_LOGOUT_STATE_COOKIE_NAME = "__Host-nihplod_sso_logout_state";
|
|
12
|
+
function toInsecureCookieName(name) {
|
|
13
|
+
return name.replace(/^__(Host|Secure)-/, "");
|
|
14
|
+
}
|
|
15
|
+
function resolveInsecureLocalDev(enabled, ssoBaseUrl) {
|
|
16
|
+
if (!enabled) return false;
|
|
17
|
+
if (process.env.NODE_ENV === "production" && /^https:\/\//i.test(ssoBaseUrl)) {
|
|
18
|
+
console.warn(
|
|
19
|
+
"[SSO SDK] insecureLocalDev=true \u5DF2\u88AB\u5FFD\u7565\uFF1A\u5F53\u524D\u4E3A\u751F\u4EA7\u73AF\u5883\uFF08NODE_ENV=production\uFF09\u4E14 ssoBaseUrl \u4F7F\u7528 HTTPS\uFF0CCookie \u4ECD\u4FDD\u6301 Secure \u5C5E\u6027\u4E0E __Host-/__Secure- \u524D\u7F00\u3002\u8BF7\u4ECE\u751F\u4EA7\u914D\u7F6E\u4E2D\u79FB\u9664\u8BE5\u5F00\u5173\u3002"
|
|
20
|
+
);
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
console.warn(
|
|
24
|
+
"[SSO SDK] insecureLocalDev=true\uFF1ACookie \u7684 Secure \u5C5E\u6027\u5DF2\u5173\u95ED\u4E14\u53BB\u9664 __Host-/__Secure- \u524D\u7F00\u3002\u4EC5\u9650 http://localhost \u672C\u5730\u5F00\u53D1\u4F7F\u7528\uFF0C\u751F\u4EA7\u73AF\u5883\u5FC5\u987B\u79FB\u9664\u8BE5\u914D\u7F6E\uFF08\u751F\u4EA7\u5FC5\u987B\u7528 HTTPS\uFF09\u3002"
|
|
25
|
+
);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
function getHostCookieOptions(maxAge, secure = true) {
|
|
12
29
|
return {
|
|
13
30
|
httpOnly: true,
|
|
14
|
-
secure
|
|
31
|
+
secure,
|
|
15
32
|
sameSite: "lax",
|
|
16
33
|
path: "/",
|
|
17
34
|
...maxAge !== void 0 ? { maxAge } : {}
|
|
18
35
|
};
|
|
19
36
|
}
|
|
20
|
-
function getSecureCookieOptions(maxAge, path = "/") {
|
|
37
|
+
function getSecureCookieOptions(maxAge, path = "/", secure = true) {
|
|
21
38
|
return {
|
|
22
39
|
httpOnly: true,
|
|
23
|
-
secure
|
|
40
|
+
secure,
|
|
24
41
|
sameSite: "lax",
|
|
25
42
|
path,
|
|
26
43
|
...maxAge !== void 0 ? { maxAge } : {}
|
|
@@ -61,7 +78,43 @@ function matchesPath(pathname, paths) {
|
|
|
61
78
|
return false;
|
|
62
79
|
});
|
|
63
80
|
}
|
|
81
|
+
var introspectionCache = /* @__PURE__ */ new Map();
|
|
82
|
+
var INTROSPECT_CACHE_TTL_MS = 3e4;
|
|
83
|
+
var INTROSPECT_CACHE_MAX_ENTRIES = 500;
|
|
84
|
+
async function introspectCacheKey(token, clientId) {
|
|
85
|
+
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
|
|
86
|
+
const hex = Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
87
|
+
return `${hex}|${clientId}`;
|
|
88
|
+
}
|
|
89
|
+
function introspectCacheGet(key) {
|
|
90
|
+
const entry = introspectionCache.get(key);
|
|
91
|
+
if (!entry) return null;
|
|
92
|
+
if (entry.until <= Date.now()) {
|
|
93
|
+
introspectionCache.delete(key);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
introspectionCache.delete(key);
|
|
97
|
+
introspectionCache.set(key, entry);
|
|
98
|
+
return entry;
|
|
99
|
+
}
|
|
100
|
+
function introspectCacheSet(key, active) {
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
for (const [k, v] of introspectionCache) {
|
|
103
|
+
if (v.until <= now) introspectionCache.delete(k);
|
|
104
|
+
}
|
|
105
|
+
while (introspectionCache.size >= INTROSPECT_CACHE_MAX_ENTRIES) {
|
|
106
|
+
const oldest = introspectionCache.keys().next();
|
|
107
|
+
if (oldest.done) break;
|
|
108
|
+
introspectionCache.delete(oldest.value);
|
|
109
|
+
}
|
|
110
|
+
introspectionCache.set(key, { active, until: now + INTROSPECT_CACHE_TTL_MS });
|
|
111
|
+
}
|
|
64
112
|
async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret) {
|
|
113
|
+
const cacheKey = await introspectCacheKey(token, clientId);
|
|
114
|
+
const cached = introspectCacheGet(cacheKey);
|
|
115
|
+
if (cached) {
|
|
116
|
+
return cached.active;
|
|
117
|
+
}
|
|
65
118
|
try {
|
|
66
119
|
const body = new URLSearchParams({
|
|
67
120
|
token,
|
|
@@ -76,9 +129,17 @@ async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret)
|
|
|
76
129
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
77
130
|
body: body.toString()
|
|
78
131
|
});
|
|
79
|
-
if (!res.ok)
|
|
132
|
+
if (!res.ok) {
|
|
133
|
+
if (res.status !== 401 && res.status !== 403) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
introspectCacheSet(cacheKey, false);
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
80
139
|
const data = await res.json();
|
|
81
|
-
|
|
140
|
+
const active = data.active === true;
|
|
141
|
+
introspectCacheSet(cacheKey, active);
|
|
142
|
+
return active;
|
|
82
143
|
} catch {
|
|
83
144
|
return false;
|
|
84
145
|
}
|
|
@@ -93,12 +154,28 @@ function createSsoMiddleware(config) {
|
|
|
93
154
|
publicPaths = [],
|
|
94
155
|
callbackPath = "/api/auth/callback",
|
|
95
156
|
ssoCookieName = "__Host-user_token",
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
returnUrlCookieName = DEFAULT_RETURN_COOKIE_NAME,
|
|
99
|
-
verifierCookieName = DEFAULT_VERIFIER_COOKIE_NAME
|
|
157
|
+
validateSsoCookie = true,
|
|
158
|
+
insecureLocalDev: insecureLocalDevOpt = false
|
|
100
159
|
} = config;
|
|
160
|
+
const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
|
|
161
|
+
const secureCookies = !insecureLocalDev;
|
|
162
|
+
const accessTokenCookieName = insecureLocalDev ? toInsecureCookieName(config.accessTokenCookieName ?? DEFAULT_ACCESS_TOKEN_COOKIE_NAME) : config.accessTokenCookieName ?? DEFAULT_ACCESS_TOKEN_COOKIE_NAME;
|
|
163
|
+
const stateCookieName = insecureLocalDev ? toInsecureCookieName(config.stateCookieName ?? DEFAULT_STATE_COOKIE_NAME) : config.stateCookieName ?? DEFAULT_STATE_COOKIE_NAME;
|
|
164
|
+
const returnUrlCookieName = insecureLocalDev ? toInsecureCookieName(config.returnUrlCookieName ?? DEFAULT_RETURN_COOKIE_NAME) : config.returnUrlCookieName ?? DEFAULT_RETURN_COOKIE_NAME;
|
|
165
|
+
const verifierCookieName = insecureLocalDev ? toInsecureCookieName(config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME) : config.verifierCookieName ?? DEFAULT_VERIFIER_COOKIE_NAME;
|
|
101
166
|
const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
|
|
167
|
+
if (process.env.NODE_ENV !== "production") {
|
|
168
|
+
if (!validateSsoCookie) {
|
|
169
|
+
console.warn(
|
|
170
|
+
"[SSO SDK] validateSsoCookie=false\uFF1A\u4E2D\u95F4\u4EF6\u4EC5\u68C0\u67E5 Cookie \u5B58\u5728\u6027\uFF0C\u53EF\u80FD\u653E\u884C\u5DF2\u5931\u6548\u7684\u4F1A\u8BDD\u3002\u4E2D\u95F4\u4EF6\u53EA\u662F UX \u5C42\uFF0C\u654F\u611F\u6570\u636E\u7684\u9274\u6743\u5FC5\u987B\u5728 Route Handler / Server Component \u4E2D\u5B8C\u6210\u3002"
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
if (!clientSecret) {
|
|
174
|
+
console.warn(
|
|
175
|
+
"[SSO SDK] \u672A\u914D\u7F6E clientSecret\uFF08Public Client \u6A21\u5F0F\uFF09\uFF1Aintrospection \u65E0\u5BA2\u6237\u7AEF\u8BA4\u8BC1\uFF0C\u4E2D\u95F4\u4EF6\u5224\u5B9A\u7ED3\u679C\u4EC5\u4F5C UX \u53C2\u8003\u3002Confidential Client\uFF08BFF\uFF09\u8BF7\u914D\u7F6E clientSecret\u3002"
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
102
179
|
return async function ssoMiddleware(request) {
|
|
103
180
|
const { pathname } = request.nextUrl;
|
|
104
181
|
if (pathname.startsWith("/_next/") || pathname.startsWith("/favicon.ico") || pathname.match(/\.(ico|png|jpg|jpeg|svg|css|js|woff2?)$/)) {
|
|
@@ -113,7 +190,19 @@ function createSsoMiddleware(config) {
|
|
|
113
190
|
}
|
|
114
191
|
const ssoSession = request.cookies.get(ssoCookieName);
|
|
115
192
|
if (ssoSession?.value) {
|
|
116
|
-
|
|
193
|
+
if (validateSsoCookie) {
|
|
194
|
+
const tokenActive = await introspectAccessToken(
|
|
195
|
+
ssoSession.value,
|
|
196
|
+
normalizedBase,
|
|
197
|
+
clientId,
|
|
198
|
+
clientSecret
|
|
199
|
+
);
|
|
200
|
+
if (tokenActive) {
|
|
201
|
+
return NextResponse.next();
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
return NextResponse.next();
|
|
205
|
+
}
|
|
117
206
|
}
|
|
118
207
|
const accessTokenCookie = request.cookies.get(accessTokenCookieName);
|
|
119
208
|
if (accessTokenCookie?.value) {
|
|
@@ -141,11 +230,12 @@ function createSsoMiddleware(config) {
|
|
|
141
230
|
const loginUrl = new URL("/api/oauth/authorize", normalizedBase);
|
|
142
231
|
loginUrl.search = authorizeParams.toString();
|
|
143
232
|
const response = NextResponse.redirect(loginUrl);
|
|
144
|
-
response.cookies.set(stateCookieName, state, getHostCookieOptions(600));
|
|
145
|
-
response.cookies.set(verifierCookieName, verifier, getSecureCookieOptions(600, callbackPath));
|
|
146
|
-
|
|
233
|
+
response.cookies.set(stateCookieName, state, getHostCookieOptions(600, secureCookies));
|
|
234
|
+
response.cookies.set(verifierCookieName, verifier, getSecureCookieOptions(600, callbackPath, secureCookies));
|
|
235
|
+
const safeReturnUrl = (request.nextUrl.pathname + request.nextUrl.search).slice(0, 2048);
|
|
236
|
+
response.cookies.set(returnUrlCookieName, safeReturnUrl, getHostCookieOptions(600, secureCookies));
|
|
147
237
|
if (accessTokenCookie?.value) {
|
|
148
|
-
response.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0));
|
|
238
|
+
response.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
149
239
|
}
|
|
150
240
|
return response;
|
|
151
241
|
};
|
|
@@ -153,8 +243,22 @@ function createSsoMiddleware(config) {
|
|
|
153
243
|
|
|
154
244
|
// src/next/callback.ts
|
|
155
245
|
import { NextResponse as NextResponse2 } from "next/server";
|
|
246
|
+
|
|
247
|
+
// src/core/errors.ts
|
|
248
|
+
var SsoError = class extends Error {
|
|
249
|
+
constructor(code, description, cause) {
|
|
250
|
+
super(`[SSO SDK] ${code}: ${description}`);
|
|
251
|
+
this.name = "SsoError";
|
|
252
|
+
this.code = code;
|
|
253
|
+
this.description = description;
|
|
254
|
+
this.cause = cause;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// src/core/security.ts
|
|
156
259
|
function isTrustedReturnUrl(url, currentOrigin) {
|
|
157
260
|
if (!url) return false;
|
|
261
|
+
if (url.includes("\\")) return false;
|
|
158
262
|
if (url.startsWith("/") && !url.startsWith("//")) return true;
|
|
159
263
|
try {
|
|
160
264
|
return new URL(url).origin === currentOrigin;
|
|
@@ -162,6 +266,23 @@ function isTrustedReturnUrl(url, currentOrigin) {
|
|
|
162
266
|
return false;
|
|
163
267
|
}
|
|
164
268
|
}
|
|
269
|
+
function timingSafeEqualString(a, b) {
|
|
270
|
+
const ba = new TextEncoder().encode(a);
|
|
271
|
+
const bb = new TextEncoder().encode(b);
|
|
272
|
+
if (ba.length === 0 || bb.length === 0) return ba.length === bb.length;
|
|
273
|
+
const len = Math.max(ba.length, bb.length);
|
|
274
|
+
let diff = ba.length ^ bb.length;
|
|
275
|
+
for (let i = 0; i < len; i++) {
|
|
276
|
+
diff |= ba[i % ba.length] ^ bb[i % bb.length];
|
|
277
|
+
}
|
|
278
|
+
return diff === 0;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// src/core/id-token.ts
|
|
282
|
+
var _crypto = typeof crypto !== "undefined" && crypto.subtle ? crypto : null;
|
|
283
|
+
function getCrypto() {
|
|
284
|
+
return _crypto ?? null;
|
|
285
|
+
}
|
|
165
286
|
function base64UrlDecode(input) {
|
|
166
287
|
const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
167
288
|
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
|
|
@@ -192,11 +313,40 @@ function decodeJwtHeader(token) {
|
|
|
192
313
|
return null;
|
|
193
314
|
}
|
|
194
315
|
}
|
|
195
|
-
|
|
316
|
+
function normalizeIssuer(url) {
|
|
317
|
+
return url.replace(/\/+$/, "");
|
|
318
|
+
}
|
|
319
|
+
var cachedJwks = null;
|
|
320
|
+
var cachedDiscovery = null;
|
|
321
|
+
var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
322
|
+
async function fetchDiscoveryDoc(baseUrl) {
|
|
323
|
+
const now = Date.now();
|
|
324
|
+
if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
|
|
325
|
+
return cachedDiscovery.doc;
|
|
326
|
+
}
|
|
327
|
+
try {
|
|
328
|
+
const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`);
|
|
329
|
+
const doc = res.ok ? await res.json() : null;
|
|
330
|
+
cachedDiscovery = { baseUrl, doc, fetchedAt: now };
|
|
331
|
+
return doc;
|
|
332
|
+
} catch {
|
|
333
|
+
cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
async function fetchJwks(baseUrl, options = {}) {
|
|
338
|
+
const now = Date.now();
|
|
339
|
+
if (!options.forceRefresh && cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
|
|
340
|
+
return cachedJwks.jwks;
|
|
341
|
+
}
|
|
342
|
+
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
343
|
+
const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
|
|
196
344
|
try {
|
|
197
|
-
const res = await fetch(
|
|
345
|
+
const res = await fetch(jwksUri, options.forceRefresh ? { cache: "no-cache" } : void 0);
|
|
198
346
|
if (!res.ok) return null;
|
|
199
|
-
|
|
347
|
+
const jwks = await res.json();
|
|
348
|
+
cachedJwks = { baseUrl, jwks, fetchedAt: now };
|
|
349
|
+
return jwks;
|
|
200
350
|
} catch {
|
|
201
351
|
return null;
|
|
202
352
|
}
|
|
@@ -205,7 +355,9 @@ async function verifyRs256Signature(token, jwk) {
|
|
|
205
355
|
try {
|
|
206
356
|
const [headerB64, payloadB64, signature] = token.split(".");
|
|
207
357
|
if (!signature || !jwk.n || !jwk.e) return false;
|
|
208
|
-
const
|
|
358
|
+
const c = getCrypto();
|
|
359
|
+
if (!c) return false;
|
|
360
|
+
const cryptoKey = await c.subtle.importKey(
|
|
209
361
|
"jwk",
|
|
210
362
|
{ kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: false },
|
|
211
363
|
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
@@ -214,7 +366,7 @@ async function verifyRs256Signature(token, jwk) {
|
|
|
214
366
|
);
|
|
215
367
|
const signatureBytes = base64UrlDecode(signature);
|
|
216
368
|
const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
|
|
217
|
-
return await
|
|
369
|
+
return await c.subtle.verify(
|
|
218
370
|
"RSASSA-PKCS1-v1_5",
|
|
219
371
|
cryptoKey,
|
|
220
372
|
signatureBytes,
|
|
@@ -225,10 +377,9 @@ async function verifyRs256Signature(token, jwk) {
|
|
|
225
377
|
}
|
|
226
378
|
}
|
|
227
379
|
async function computeAtHash(accessToken) {
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
);
|
|
380
|
+
const c = getCrypto();
|
|
381
|
+
if (!c) return "";
|
|
382
|
+
const hash = await c.subtle.digest("SHA-256", new TextEncoder().encode(accessToken));
|
|
232
383
|
const bytes = new Uint8Array(hash);
|
|
233
384
|
const half = bytes.slice(0, bytes.length / 2);
|
|
234
385
|
let binary = "";
|
|
@@ -237,54 +388,101 @@ async function computeAtHash(accessToken) {
|
|
|
237
388
|
}
|
|
238
389
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
239
390
|
}
|
|
240
|
-
async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId) {
|
|
391
|
+
async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
|
|
392
|
+
const { rejectHs256WhenRs256Available = true } = options;
|
|
241
393
|
const header = decodeJwtHeader(idToken);
|
|
242
|
-
if (!header)
|
|
394
|
+
if (!header) {
|
|
395
|
+
throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
|
|
396
|
+
}
|
|
243
397
|
const alg = header.alg;
|
|
244
398
|
if (typeof alg !== "string" || alg !== "RS256" && alg !== "HS256") {
|
|
245
|
-
throw new
|
|
399
|
+
throw new SsoError("id_token_unsupported_alg", `\u4E0D\u652F\u6301\u7684 ID Token \u7B7E\u540D\u7B97\u6CD5: ${alg}`);
|
|
246
400
|
}
|
|
401
|
+
const baseUrl = normalizeIssuer(expectedIssuer);
|
|
402
|
+
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
403
|
+
const normalizedIssuer = normalizeIssuer(discovery?.issuer || baseUrl);
|
|
247
404
|
if (alg === "RS256") {
|
|
248
|
-
const jwks = await fetchJwks(
|
|
249
|
-
if (!jwks)
|
|
405
|
+
const jwks = await fetchJwks(baseUrl);
|
|
406
|
+
if (!jwks) {
|
|
407
|
+
throw new SsoError("id_token_invalid_signature", "\u65E0\u6CD5\u83B7\u53D6 JWKS \u9A8C\u8BC1 ID Token \u7B7E\u540D");
|
|
408
|
+
}
|
|
250
409
|
const kid = typeof header.kid === "string" ? header.kid : void 0;
|
|
251
|
-
const
|
|
410
|
+
const matchCandidates = (set) => set.keys.filter(
|
|
252
411
|
(k) => k.kty === "RSA" && k.alg === "RS256" && k.use === "sig" && (kid ? k.kid === kid : true)
|
|
253
412
|
);
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
413
|
+
const verifyAny = async (keys) => {
|
|
414
|
+
for (const key of keys) {
|
|
415
|
+
if (await verifyRs256Signature(idToken, key)) return true;
|
|
416
|
+
}
|
|
417
|
+
return false;
|
|
418
|
+
};
|
|
419
|
+
let candidates = matchCandidates(jwks);
|
|
420
|
+
let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
|
|
421
|
+
if (candidates.length === 0 || !validSig) {
|
|
422
|
+
const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });
|
|
423
|
+
if (freshJwks) {
|
|
424
|
+
candidates = matchCandidates(freshJwks);
|
|
425
|
+
validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (candidates.length === 0) {
|
|
429
|
+
throw new SsoError("id_token_invalid_signature", "JWKS \u4E2D\u672A\u627E\u5230\u5339\u914D\u7684 RS256 \u516C\u94A5");
|
|
430
|
+
}
|
|
431
|
+
if (!validSig) {
|
|
432
|
+
throw new SsoError("id_token_invalid_signature", "ID Token \u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25");
|
|
433
|
+
}
|
|
257
434
|
}
|
|
258
435
|
if (alg === "HS256") {
|
|
259
|
-
|
|
260
|
-
|
|
436
|
+
if (rejectHs256WhenRs256Available) {
|
|
437
|
+
const jwks = await fetchJwks(baseUrl);
|
|
438
|
+
const hasRs256 = jwks?.keys?.some((k) => k.alg === "RS256" && k.use === "sig");
|
|
439
|
+
if (hasRs256) {
|
|
440
|
+
throw new SsoError("id_token_unsupported_alg", "SSO \u4E2D\u5FC3\u5DF2\u914D\u7F6E RS256\uFF0C\u62D2\u7EDD HS256 ID Token");
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
throw new SsoError(
|
|
444
|
+
"id_token_hs256_unsupported",
|
|
445
|
+
"ID Token \u4F7F\u7528 HS256 \u5BF9\u79F0\u7B7E\u540D\uFF0CSDK \u65E0\u6CD5\u5B89\u5168\u9A8C\u8BC1\u3002\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u5728 SSO \u4E3B\u7AD9\u914D\u7F6E RS256 \u5BC6\u94A5\u5BF9\uFF08JWT_ID_TOKEN_PRIVATE_KEY / JWT_ID_TOKEN_PUBLIC_KEY\uFF09\u540E\u91CD\u65B0\u7B7E\u53D1\u3002"
|
|
261
446
|
);
|
|
262
447
|
}
|
|
263
448
|
const payload = decodeJwtPayload(idToken);
|
|
264
|
-
if (!payload)
|
|
265
|
-
|
|
266
|
-
|
|
449
|
+
if (!payload) {
|
|
450
|
+
throw new SsoError("id_token_invalid", "ID Token payload \u89E3\u6790\u5931\u8D25");
|
|
451
|
+
}
|
|
452
|
+
const tokenIssuer = typeof payload.iss === "string" ? normalizeIssuer(payload.iss) : "";
|
|
267
453
|
if (tokenIssuer !== normalizedIssuer) {
|
|
268
|
-
throw new
|
|
454
|
+
throw new SsoError("id_token_issuer_mismatch", "ID Token issuer \u4E0D\u5339\u914D");
|
|
269
455
|
}
|
|
270
456
|
const aud = payload.aud;
|
|
271
|
-
const
|
|
272
|
-
if (!
|
|
273
|
-
throw new
|
|
457
|
+
const audList = Array.isArray(aud) ? aud : typeof aud === "string" ? [aud] : [];
|
|
458
|
+
if (!audList.includes(expectedClientId)) {
|
|
459
|
+
throw new SsoError("id_token_audience_mismatch", "ID Token audience \u4E0D\u5339\u914D");
|
|
460
|
+
}
|
|
461
|
+
if (audList.length > 1 && payload.azp !== expectedClientId) {
|
|
462
|
+
throw new SsoError("id_token_audience_mismatch", "ID Token \u591A audience \u65F6 azp \u5FC5\u987B\u4E3A\u5F53\u524D client");
|
|
274
463
|
}
|
|
275
|
-
if (typeof payload.exp
|
|
276
|
-
throw new
|
|
464
|
+
if (typeof payload.exp !== "number") {
|
|
465
|
+
throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 exp \u58F0\u660E");
|
|
466
|
+
}
|
|
467
|
+
if (Date.now() >= payload.exp * 1e3 + 6e4) {
|
|
468
|
+
throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
|
|
469
|
+
}
|
|
470
|
+
if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
|
|
471
|
+
throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
|
|
277
472
|
}
|
|
278
473
|
if (typeof payload.sub !== "string" || !payload.sub) {
|
|
279
|
-
throw new
|
|
474
|
+
throw new SsoError("id_token_missing_sub", "ID Token \u7F3A\u5C11 sub");
|
|
280
475
|
}
|
|
281
476
|
if (typeof payload.at_hash === "string" && payload.at_hash) {
|
|
282
477
|
const actual = await computeAtHash(accessToken);
|
|
283
|
-
if (actual
|
|
284
|
-
throw new
|
|
478
|
+
if (!timingSafeEqualString(actual, payload.at_hash)) {
|
|
479
|
+
throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
|
|
285
480
|
}
|
|
286
481
|
}
|
|
482
|
+
return { sub: payload.sub };
|
|
287
483
|
}
|
|
484
|
+
|
|
485
|
+
// src/next/callback.ts
|
|
288
486
|
function createCallbackRouteHandler(config) {
|
|
289
487
|
const {
|
|
290
488
|
clientId,
|
|
@@ -292,13 +490,17 @@ function createCallbackRouteHandler(config) {
|
|
|
292
490
|
redirectUri,
|
|
293
491
|
clientSecret,
|
|
294
492
|
defaultReturnPath = "/",
|
|
295
|
-
|
|
296
|
-
refreshTokenCookieName = DEFAULT_REFRESH_TOKEN_COOKIE_NAME,
|
|
297
|
-
idTokenCookieName = DEFAULT_ID_TOKEN_COOKIE_NAME,
|
|
298
|
-
stateCookieName = DEFAULT_STATE_COOKIE_NAME,
|
|
299
|
-
returnUrlCookieName = DEFAULT_RETURN_COOKIE_NAME,
|
|
300
|
-
verifierCookieName = DEFAULT_VERIFIER_COOKIE_NAME
|
|
493
|
+
insecureLocalDev: insecureLocalDevOpt = false
|
|
301
494
|
} = config;
|
|
495
|
+
const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
|
|
496
|
+
const secureCookies = !insecureLocalDev;
|
|
497
|
+
const pickName = (explicit, fallback) => insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : explicit ?? fallback;
|
|
498
|
+
const accessTokenCookieName = pickName(config.accessTokenCookieName, DEFAULT_ACCESS_TOKEN_COOKIE_NAME);
|
|
499
|
+
const refreshTokenCookieName = pickName(config.refreshTokenCookieName, DEFAULT_REFRESH_TOKEN_COOKIE_NAME);
|
|
500
|
+
const idTokenCookieName = pickName(config.idTokenCookieName, DEFAULT_ID_TOKEN_COOKIE_NAME);
|
|
501
|
+
const stateCookieName = pickName(config.stateCookieName, DEFAULT_STATE_COOKIE_NAME);
|
|
502
|
+
const returnUrlCookieName = pickName(config.returnUrlCookieName, DEFAULT_RETURN_COOKIE_NAME);
|
|
503
|
+
const verifierCookieName = pickName(config.verifierCookieName, DEFAULT_VERIFIER_COOKIE_NAME);
|
|
302
504
|
const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
|
|
303
505
|
return async function GET(request) {
|
|
304
506
|
const { searchParams } = request.nextUrl;
|
|
@@ -360,7 +562,7 @@ function createCallbackRouteHandler(config) {
|
|
|
360
562
|
if (clientSecret) {
|
|
361
563
|
body.set("client_secret", clientSecret);
|
|
362
564
|
}
|
|
363
|
-
let res;
|
|
565
|
+
let res = null;
|
|
364
566
|
let lastError;
|
|
365
567
|
const maxRetries = 1;
|
|
366
568
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
@@ -406,6 +608,15 @@ function createCallbackRouteHandler(config) {
|
|
|
406
608
|
);
|
|
407
609
|
}
|
|
408
610
|
const tokenData = await res.json();
|
|
611
|
+
if (!tokenData.access_token || !tokenData.refresh_token) {
|
|
612
|
+
return NextResponse2.json(
|
|
613
|
+
{
|
|
614
|
+
error: "server_error",
|
|
615
|
+
error_description: "Token \u54CD\u5E94\u7F3A\u5C11 access_token \u6216 refresh_token"
|
|
616
|
+
},
|
|
617
|
+
{ status: 502 }
|
|
618
|
+
);
|
|
619
|
+
}
|
|
409
620
|
if (tokenData.id_token) {
|
|
410
621
|
try {
|
|
411
622
|
await validateIdToken(
|
|
@@ -428,21 +639,36 @@ function createCallbackRouteHandler(config) {
|
|
|
428
639
|
const returnUrl = isTrustedReturnUrl(rawReturnUrl, request.nextUrl.origin) ? rawReturnUrl : "/";
|
|
429
640
|
const response = NextResponse2.redirect(new URL(returnUrl, request.url));
|
|
430
641
|
response.cookies.set(accessTokenCookieName, tokenData.access_token, {
|
|
431
|
-
...getHostCookieOptions(tokenData.expires_in)
|
|
642
|
+
...getHostCookieOptions(tokenData.expires_in, secureCookies)
|
|
432
643
|
});
|
|
433
644
|
const refreshMaxAge = tokenData.refresh_expires_in != null ? tokenData.refresh_expires_in : 30 * 24 * 60 * 60;
|
|
434
645
|
response.cookies.set(refreshTokenCookieName, tokenData.refresh_token, {
|
|
435
|
-
...getHostCookieOptions(refreshMaxAge)
|
|
646
|
+
...getHostCookieOptions(refreshMaxAge, secureCookies)
|
|
436
647
|
});
|
|
437
648
|
if (tokenData.id_token) {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
649
|
+
let idTokenMaxAge = 3600;
|
|
650
|
+
try {
|
|
651
|
+
const parts = tokenData.id_token.split(".");
|
|
652
|
+
if (parts.length === 3) {
|
|
653
|
+
const payload = JSON.parse(
|
|
654
|
+
Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")
|
|
655
|
+
);
|
|
656
|
+
if (payload.exp && typeof payload.exp === "number") {
|
|
657
|
+
idTokenMaxAge = payload.exp - Math.floor(Date.now() / 1e3);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
} catch {
|
|
661
|
+
}
|
|
662
|
+
if (idTokenMaxAge > 0) {
|
|
663
|
+
response.cookies.set(idTokenCookieName, tokenData.id_token, {
|
|
664
|
+
...getHostCookieOptions(idTokenMaxAge, secureCookies)
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
response.cookies.set(stateCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
669
|
+
response.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
670
|
+
response.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/", secureCookies));
|
|
671
|
+
response.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, request.nextUrl.pathname, secureCookies));
|
|
446
672
|
return response;
|
|
447
673
|
};
|
|
448
674
|
}
|
|
@@ -450,9 +676,9 @@ function createCallbackRouteHandler(config) {
|
|
|
450
676
|
// src/next/logout.ts
|
|
451
677
|
import { NextResponse as NextResponse3 } from "next/server";
|
|
452
678
|
async function fetchDiscovery(ssoBaseUrl) {
|
|
679
|
+
const controller = new AbortController();
|
|
680
|
+
const timeoutId = setTimeout(() => controller.abort(), 5e3);
|
|
453
681
|
try {
|
|
454
|
-
const controller = new AbortController();
|
|
455
|
-
setTimeout(() => controller.abort(), 5e3);
|
|
456
682
|
const res = await fetch(
|
|
457
683
|
`${ssoBaseUrl}/api/oauth/.well-known/openid-configuration`,
|
|
458
684
|
{ signal: controller.signal }
|
|
@@ -461,7 +687,23 @@ async function fetchDiscovery(ssoBaseUrl) {
|
|
|
461
687
|
return await res.json();
|
|
462
688
|
} catch {
|
|
463
689
|
return null;
|
|
690
|
+
} finally {
|
|
691
|
+
clearTimeout(timeoutId);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
function generateRandomString2(length) {
|
|
695
|
+
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
|
|
696
|
+
const maxValid = Math.floor(256 / chars.length) * chars.length;
|
|
697
|
+
let result = "";
|
|
698
|
+
while (result.length < length) {
|
|
699
|
+
const array = new Uint8Array(length * 2);
|
|
700
|
+
crypto.getRandomValues(array);
|
|
701
|
+
for (let i = 0; i < array.length && result.length < length; i++) {
|
|
702
|
+
if (array[i] >= maxValid) continue;
|
|
703
|
+
result += chars[array[i] % chars.length];
|
|
704
|
+
}
|
|
464
705
|
}
|
|
706
|
+
return result;
|
|
465
707
|
}
|
|
466
708
|
function createLogoutRouteHandler(config) {
|
|
467
709
|
const {
|
|
@@ -471,16 +713,34 @@ function createLogoutRouteHandler(config) {
|
|
|
471
713
|
clientSecret,
|
|
472
714
|
postLogoutRedirectUri = new URL(redirectUri).origin + "/",
|
|
473
715
|
redirectToSso = true,
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
idTokenCookieName = DEFAULT_ID_TOKEN_COOKIE_NAME,
|
|
477
|
-
stateCookieName = DEFAULT_STATE_COOKIE_NAME,
|
|
478
|
-
returnUrlCookieName = DEFAULT_RETURN_COOKIE_NAME,
|
|
479
|
-
verifierCookieName = DEFAULT_VERIFIER_COOKIE_NAME,
|
|
480
|
-
callbackPath = "/api/auth/callback"
|
|
716
|
+
callbackPath = "/api/auth/callback",
|
|
717
|
+
insecureLocalDev: insecureLocalDevOpt = false
|
|
481
718
|
} = config;
|
|
719
|
+
const insecureLocalDev = resolveInsecureLocalDev(insecureLocalDevOpt, ssoBaseUrl);
|
|
720
|
+
const secureCookies = !insecureLocalDev;
|
|
721
|
+
const pickName = (explicit, fallback) => insecureLocalDev ? toInsecureCookieName(explicit ?? fallback) : explicit ?? fallback;
|
|
722
|
+
const accessTokenCookieName = pickName(config.accessTokenCookieName, DEFAULT_ACCESS_TOKEN_COOKIE_NAME);
|
|
723
|
+
const refreshTokenCookieName = pickName(config.refreshTokenCookieName, DEFAULT_REFRESH_TOKEN_COOKIE_NAME);
|
|
724
|
+
const idTokenCookieName = pickName(config.idTokenCookieName, DEFAULT_ID_TOKEN_COOKIE_NAME);
|
|
725
|
+
const stateCookieName = pickName(config.stateCookieName, DEFAULT_STATE_COOKIE_NAME);
|
|
726
|
+
const returnUrlCookieName = pickName(config.returnUrlCookieName, DEFAULT_RETURN_COOKIE_NAME);
|
|
727
|
+
const verifierCookieName = pickName(config.verifierCookieName, DEFAULT_VERIFIER_COOKIE_NAME);
|
|
728
|
+
const logoutStateCookieName = pickName(config.logoutStateCookieName, DEFAULT_LOGOUT_STATE_COOKIE_NAME);
|
|
482
729
|
const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
|
|
483
|
-
return async function
|
|
730
|
+
return async function handler(request) {
|
|
731
|
+
const returnedState = request.nextUrl.searchParams.get("state");
|
|
732
|
+
if (returnedState) {
|
|
733
|
+
const savedState = request.cookies.get(logoutStateCookieName)?.value;
|
|
734
|
+
if (!savedState || savedState !== returnedState) {
|
|
735
|
+
return NextResponse3.json(
|
|
736
|
+
{ error: "invalid_request", error_description: "Logout state \u4E0D\u5339\u914D" },
|
|
737
|
+
{ status: 400 }
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
const res = NextResponse3.redirect(request.nextUrl.origin + "/");
|
|
741
|
+
res.cookies.set(logoutStateCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
742
|
+
return res;
|
|
743
|
+
}
|
|
484
744
|
const refreshToken = request.cookies.get(refreshTokenCookieName)?.value;
|
|
485
745
|
const idTokenHint = request.cookies.get(idTokenCookieName)?.value;
|
|
486
746
|
if (refreshToken) {
|
|
@@ -504,18 +764,18 @@ function createLogoutRouteHandler(config) {
|
|
|
504
764
|
}
|
|
505
765
|
}
|
|
506
766
|
const clearCookies = (res) => {
|
|
507
|
-
res.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0));
|
|
508
|
-
res.cookies.set(refreshTokenCookieName, "", getHostCookieOptions(0));
|
|
509
|
-
res.cookies.set(idTokenCookieName, "", getHostCookieOptions(0));
|
|
510
|
-
res.cookies.set(stateCookieName, "", getHostCookieOptions(0));
|
|
511
|
-
res.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0));
|
|
512
|
-
res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/"));
|
|
513
|
-
res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, callbackPath));
|
|
767
|
+
res.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
768
|
+
res.cookies.set(refreshTokenCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
769
|
+
res.cookies.set(idTokenCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
770
|
+
res.cookies.set(stateCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
771
|
+
res.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0, secureCookies));
|
|
772
|
+
res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, "/", secureCookies));
|
|
773
|
+
res.cookies.set(verifierCookieName, "", getSecureCookieOptions(0, callbackPath, secureCookies));
|
|
514
774
|
return res;
|
|
515
775
|
};
|
|
516
776
|
if (redirectToSso) {
|
|
517
777
|
const discovery = await fetchDiscovery(normalizedBase);
|
|
518
|
-
const endSessionEndpoint = discovery?.end_session_endpoint || `${normalizedBase}/
|
|
778
|
+
const endSessionEndpoint = discovery?.end_session_endpoint || `${normalizedBase}/api/oauth/end-session`;
|
|
519
779
|
const logoutUrl = new URL(endSessionEndpoint);
|
|
520
780
|
logoutUrl.searchParams.set("client_id", clientId);
|
|
521
781
|
logoutUrl.searchParams.set(
|
|
@@ -525,7 +785,11 @@ function createLogoutRouteHandler(config) {
|
|
|
525
785
|
if (idTokenHint) {
|
|
526
786
|
logoutUrl.searchParams.set("id_token_hint", idTokenHint);
|
|
527
787
|
}
|
|
528
|
-
|
|
788
|
+
const logoutState = generateRandomString2(32);
|
|
789
|
+
logoutUrl.searchParams.set("state", logoutState);
|
|
790
|
+
const res = clearCookies(NextResponse3.redirect(logoutUrl.toString()));
|
|
791
|
+
res.cookies.set(logoutStateCookieName, logoutState, getHostCookieOptions(600, secureCookies));
|
|
792
|
+
return res;
|
|
529
793
|
}
|
|
530
794
|
return clearCookies(
|
|
531
795
|
NextResponse3.redirect(request.nextUrl.origin + "/")
|
|
@@ -534,6 +798,8 @@ function createLogoutRouteHandler(config) {
|
|
|
534
798
|
}
|
|
535
799
|
export {
|
|
536
800
|
DEFAULT_ACCESS_TOKEN_COOKIE_NAME,
|
|
801
|
+
DEFAULT_ID_TOKEN_COOKIE_NAME,
|
|
802
|
+
DEFAULT_LOGOUT_STATE_COOKIE_NAME,
|
|
537
803
|
DEFAULT_REFRESH_TOKEN_COOKIE_NAME,
|
|
538
804
|
DEFAULT_RETURN_COOKIE_NAME,
|
|
539
805
|
DEFAULT_STATE_COOKIE_NAME,
|
|
@@ -542,6 +808,7 @@ export {
|
|
|
542
808
|
createLogoutRouteHandler,
|
|
543
809
|
createSsoMiddleware,
|
|
544
810
|
getHostCookieOptions,
|
|
545
|
-
getSecureCookieOptions
|
|
811
|
+
getSecureCookieOptions,
|
|
812
|
+
toInsecureCookieName
|
|
546
813
|
};
|
|
547
814
|
//# sourceMappingURL=index.mjs.map
|