@nihplod/sso-sdk 1.0.0 → 1.1.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 +374 -360
- package/dist/index.d.mts +99 -12
- package/dist/index.d.ts +99 -12
- package/dist/index.js +413 -75
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +408 -74
- package/dist/index.mjs.map +1 -1
- package/dist/next/index.d.mts +17 -2
- package/dist/next/index.d.ts +17 -2
- package/dist/next/index.js +276 -45
- package/dist/next/index.js.map +1 -1
- package/dist/next/index.mjs +274 -45
- package/dist/next/index.mjs.map +1 -1
- package/dist/react/index.d.mts +71 -8
- package/dist/react/index.d.ts +71 -8
- package/dist/react/index.js +476 -111
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +476 -111
- package/dist/react/index.mjs.map +1 -1
- package/package.json +3 -2
package/dist/next/index.mjs
CHANGED
|
@@ -8,6 +8,7 @@ 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
|
+
var DEFAULT_LOGOUT_STATE_COOKIE_NAME = "__Host-nihplod_sso_logout_state";
|
|
11
12
|
function getHostCookieOptions(maxAge) {
|
|
12
13
|
return {
|
|
13
14
|
httpOnly: true,
|
|
@@ -61,7 +62,43 @@ function matchesPath(pathname, paths) {
|
|
|
61
62
|
return false;
|
|
62
63
|
});
|
|
63
64
|
}
|
|
65
|
+
var introspectionCache = /* @__PURE__ */ new Map();
|
|
66
|
+
var INTROSPECT_CACHE_TTL_MS = 3e4;
|
|
67
|
+
var INTROSPECT_CACHE_MAX_ENTRIES = 500;
|
|
68
|
+
async function introspectCacheKey(token, clientId) {
|
|
69
|
+
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
|
|
70
|
+
const hex = Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
71
|
+
return `${hex}|${clientId}`;
|
|
72
|
+
}
|
|
73
|
+
function introspectCacheGet(key) {
|
|
74
|
+
const entry = introspectionCache.get(key);
|
|
75
|
+
if (!entry) return null;
|
|
76
|
+
if (entry.until <= Date.now()) {
|
|
77
|
+
introspectionCache.delete(key);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
introspectionCache.delete(key);
|
|
81
|
+
introspectionCache.set(key, entry);
|
|
82
|
+
return entry;
|
|
83
|
+
}
|
|
84
|
+
function introspectCacheSet(key, active) {
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
for (const [k, v] of introspectionCache) {
|
|
87
|
+
if (v.until <= now) introspectionCache.delete(k);
|
|
88
|
+
}
|
|
89
|
+
while (introspectionCache.size >= INTROSPECT_CACHE_MAX_ENTRIES) {
|
|
90
|
+
const oldest = introspectionCache.keys().next();
|
|
91
|
+
if (oldest.done) break;
|
|
92
|
+
introspectionCache.delete(oldest.value);
|
|
93
|
+
}
|
|
94
|
+
introspectionCache.set(key, { active, until: now + INTROSPECT_CACHE_TTL_MS });
|
|
95
|
+
}
|
|
64
96
|
async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret) {
|
|
97
|
+
const cacheKey = await introspectCacheKey(token, clientId);
|
|
98
|
+
const cached = introspectCacheGet(cacheKey);
|
|
99
|
+
if (cached) {
|
|
100
|
+
return cached.active;
|
|
101
|
+
}
|
|
65
102
|
try {
|
|
66
103
|
const body = new URLSearchParams({
|
|
67
104
|
token,
|
|
@@ -76,9 +113,17 @@ async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret)
|
|
|
76
113
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
77
114
|
body: body.toString()
|
|
78
115
|
});
|
|
79
|
-
if (!res.ok)
|
|
116
|
+
if (!res.ok) {
|
|
117
|
+
if (res.status !== 401 && res.status !== 403) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
introspectCacheSet(cacheKey, false);
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
80
123
|
const data = await res.json();
|
|
81
|
-
|
|
124
|
+
const active = data.active === true;
|
|
125
|
+
introspectCacheSet(cacheKey, active);
|
|
126
|
+
return active;
|
|
82
127
|
} catch {
|
|
83
128
|
return false;
|
|
84
129
|
}
|
|
@@ -93,12 +138,25 @@ function createSsoMiddleware(config) {
|
|
|
93
138
|
publicPaths = [],
|
|
94
139
|
callbackPath = "/api/auth/callback",
|
|
95
140
|
ssoCookieName = "__Host-user_token",
|
|
141
|
+
validateSsoCookie = true,
|
|
96
142
|
accessTokenCookieName = DEFAULT_ACCESS_TOKEN_COOKIE_NAME,
|
|
97
143
|
stateCookieName = DEFAULT_STATE_COOKIE_NAME,
|
|
98
144
|
returnUrlCookieName = DEFAULT_RETURN_COOKIE_NAME,
|
|
99
145
|
verifierCookieName = DEFAULT_VERIFIER_COOKIE_NAME
|
|
100
146
|
} = config;
|
|
101
147
|
const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
|
|
148
|
+
if (process.env.NODE_ENV !== "production") {
|
|
149
|
+
if (!validateSsoCookie) {
|
|
150
|
+
console.warn(
|
|
151
|
+
"[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"
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (!clientSecret) {
|
|
155
|
+
console.warn(
|
|
156
|
+
"[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"
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
102
160
|
return async function ssoMiddleware(request) {
|
|
103
161
|
const { pathname } = request.nextUrl;
|
|
104
162
|
if (pathname.startsWith("/_next/") || pathname.startsWith("/favicon.ico") || pathname.match(/\.(ico|png|jpg|jpeg|svg|css|js|woff2?)$/)) {
|
|
@@ -113,7 +171,19 @@ function createSsoMiddleware(config) {
|
|
|
113
171
|
}
|
|
114
172
|
const ssoSession = request.cookies.get(ssoCookieName);
|
|
115
173
|
if (ssoSession?.value) {
|
|
116
|
-
|
|
174
|
+
if (validateSsoCookie) {
|
|
175
|
+
const tokenActive = await introspectAccessToken(
|
|
176
|
+
ssoSession.value,
|
|
177
|
+
normalizedBase,
|
|
178
|
+
clientId,
|
|
179
|
+
clientSecret
|
|
180
|
+
);
|
|
181
|
+
if (tokenActive) {
|
|
182
|
+
return NextResponse.next();
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
return NextResponse.next();
|
|
186
|
+
}
|
|
117
187
|
}
|
|
118
188
|
const accessTokenCookie = request.cookies.get(accessTokenCookieName);
|
|
119
189
|
if (accessTokenCookie?.value) {
|
|
@@ -143,7 +213,8 @@ function createSsoMiddleware(config) {
|
|
|
143
213
|
const response = NextResponse.redirect(loginUrl);
|
|
144
214
|
response.cookies.set(stateCookieName, state, getHostCookieOptions(600));
|
|
145
215
|
response.cookies.set(verifierCookieName, verifier, getSecureCookieOptions(600, callbackPath));
|
|
146
|
-
|
|
216
|
+
const safeReturnUrl = (request.nextUrl.pathname + request.nextUrl.search).slice(0, 2048);
|
|
217
|
+
response.cookies.set(returnUrlCookieName, safeReturnUrl, getHostCookieOptions(600));
|
|
147
218
|
if (accessTokenCookie?.value) {
|
|
148
219
|
response.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0));
|
|
149
220
|
}
|
|
@@ -153,6 +224,19 @@ function createSsoMiddleware(config) {
|
|
|
153
224
|
|
|
154
225
|
// src/next/callback.ts
|
|
155
226
|
import { NextResponse as NextResponse2 } from "next/server";
|
|
227
|
+
|
|
228
|
+
// src/core/errors.ts
|
|
229
|
+
var SsoError = class extends Error {
|
|
230
|
+
constructor(code, description, cause) {
|
|
231
|
+
super(`[SSO SDK] ${code}: ${description}`);
|
|
232
|
+
this.name = "SsoError";
|
|
233
|
+
this.code = code;
|
|
234
|
+
this.description = description;
|
|
235
|
+
this.cause = cause;
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// src/core/security.ts
|
|
156
240
|
function isTrustedReturnUrl(url, currentOrigin) {
|
|
157
241
|
if (!url) return false;
|
|
158
242
|
if (url.startsWith("/") && !url.startsWith("//")) return true;
|
|
@@ -162,6 +246,23 @@ function isTrustedReturnUrl(url, currentOrigin) {
|
|
|
162
246
|
return false;
|
|
163
247
|
}
|
|
164
248
|
}
|
|
249
|
+
function timingSafeEqualString(a, b) {
|
|
250
|
+
const ba = new TextEncoder().encode(a);
|
|
251
|
+
const bb = new TextEncoder().encode(b);
|
|
252
|
+
if (ba.length === 0 || bb.length === 0) return ba.length === bb.length;
|
|
253
|
+
const len = Math.max(ba.length, bb.length);
|
|
254
|
+
let diff = ba.length ^ bb.length;
|
|
255
|
+
for (let i = 0; i < len; i++) {
|
|
256
|
+
diff |= ba[i % ba.length] ^ bb[i % bb.length];
|
|
257
|
+
}
|
|
258
|
+
return diff === 0;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// src/core/id-token.ts
|
|
262
|
+
var _crypto = typeof crypto !== "undefined" && crypto.subtle ? crypto : null;
|
|
263
|
+
function getCrypto() {
|
|
264
|
+
return _crypto ?? null;
|
|
265
|
+
}
|
|
165
266
|
function base64UrlDecode(input) {
|
|
166
267
|
const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
167
268
|
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
|
|
@@ -192,11 +293,40 @@ function decodeJwtHeader(token) {
|
|
|
192
293
|
return null;
|
|
193
294
|
}
|
|
194
295
|
}
|
|
195
|
-
|
|
296
|
+
function normalizeIssuer(url) {
|
|
297
|
+
return url.replace(/\/+$/, "");
|
|
298
|
+
}
|
|
299
|
+
var cachedJwks = null;
|
|
300
|
+
var cachedDiscovery = null;
|
|
301
|
+
var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
302
|
+
async function fetchDiscoveryDoc(baseUrl) {
|
|
303
|
+
const now = Date.now();
|
|
304
|
+
if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
|
|
305
|
+
return cachedDiscovery.doc;
|
|
306
|
+
}
|
|
196
307
|
try {
|
|
197
|
-
const res = await fetch(`${baseUrl}/api/oauth/
|
|
308
|
+
const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`);
|
|
309
|
+
const doc = res.ok ? await res.json() : null;
|
|
310
|
+
cachedDiscovery = { baseUrl, doc, fetchedAt: now };
|
|
311
|
+
return doc;
|
|
312
|
+
} catch {
|
|
313
|
+
cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
async function fetchJwks(baseUrl, options = {}) {
|
|
318
|
+
const now = Date.now();
|
|
319
|
+
if (!options.forceRefresh && cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
|
|
320
|
+
return cachedJwks.jwks;
|
|
321
|
+
}
|
|
322
|
+
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
323
|
+
const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
|
|
324
|
+
try {
|
|
325
|
+
const res = await fetch(jwksUri, options.forceRefresh ? { cache: "no-cache" } : void 0);
|
|
198
326
|
if (!res.ok) return null;
|
|
199
|
-
|
|
327
|
+
const jwks = await res.json();
|
|
328
|
+
cachedJwks = { baseUrl, jwks, fetchedAt: now };
|
|
329
|
+
return jwks;
|
|
200
330
|
} catch {
|
|
201
331
|
return null;
|
|
202
332
|
}
|
|
@@ -205,7 +335,9 @@ async function verifyRs256Signature(token, jwk) {
|
|
|
205
335
|
try {
|
|
206
336
|
const [headerB64, payloadB64, signature] = token.split(".");
|
|
207
337
|
if (!signature || !jwk.n || !jwk.e) return false;
|
|
208
|
-
const
|
|
338
|
+
const c = getCrypto();
|
|
339
|
+
if (!c) return false;
|
|
340
|
+
const cryptoKey = await c.subtle.importKey(
|
|
209
341
|
"jwk",
|
|
210
342
|
{ kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: false },
|
|
211
343
|
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
@@ -214,7 +346,7 @@ async function verifyRs256Signature(token, jwk) {
|
|
|
214
346
|
);
|
|
215
347
|
const signatureBytes = base64UrlDecode(signature);
|
|
216
348
|
const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
|
|
217
|
-
return await
|
|
349
|
+
return await c.subtle.verify(
|
|
218
350
|
"RSASSA-PKCS1-v1_5",
|
|
219
351
|
cryptoKey,
|
|
220
352
|
signatureBytes,
|
|
@@ -225,10 +357,9 @@ async function verifyRs256Signature(token, jwk) {
|
|
|
225
357
|
}
|
|
226
358
|
}
|
|
227
359
|
async function computeAtHash(accessToken) {
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
);
|
|
360
|
+
const c = getCrypto();
|
|
361
|
+
if (!c) return "";
|
|
362
|
+
const hash = await c.subtle.digest("SHA-256", new TextEncoder().encode(accessToken));
|
|
232
363
|
const bytes = new Uint8Array(hash);
|
|
233
364
|
const half = bytes.slice(0, bytes.length / 2);
|
|
234
365
|
let binary = "";
|
|
@@ -237,54 +368,101 @@ async function computeAtHash(accessToken) {
|
|
|
237
368
|
}
|
|
238
369
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
239
370
|
}
|
|
240
|
-
async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId) {
|
|
371
|
+
async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
|
|
372
|
+
const { rejectHs256WhenRs256Available = true } = options;
|
|
241
373
|
const header = decodeJwtHeader(idToken);
|
|
242
|
-
if (!header)
|
|
374
|
+
if (!header) {
|
|
375
|
+
throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
|
|
376
|
+
}
|
|
243
377
|
const alg = header.alg;
|
|
244
378
|
if (typeof alg !== "string" || alg !== "RS256" && alg !== "HS256") {
|
|
245
|
-
throw new
|
|
379
|
+
throw new SsoError("id_token_unsupported_alg", `\u4E0D\u652F\u6301\u7684 ID Token \u7B7E\u540D\u7B97\u6CD5: ${alg}`);
|
|
246
380
|
}
|
|
381
|
+
const baseUrl = normalizeIssuer(expectedIssuer);
|
|
382
|
+
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
383
|
+
const normalizedIssuer = normalizeIssuer(discovery?.issuer || baseUrl);
|
|
247
384
|
if (alg === "RS256") {
|
|
248
|
-
const jwks = await fetchJwks(
|
|
249
|
-
if (!jwks)
|
|
385
|
+
const jwks = await fetchJwks(baseUrl);
|
|
386
|
+
if (!jwks) {
|
|
387
|
+
throw new SsoError("id_token_invalid_signature", "\u65E0\u6CD5\u83B7\u53D6 JWKS \u9A8C\u8BC1 ID Token \u7B7E\u540D");
|
|
388
|
+
}
|
|
250
389
|
const kid = typeof header.kid === "string" ? header.kid : void 0;
|
|
251
|
-
const
|
|
390
|
+
const matchCandidates = (set) => set.keys.filter(
|
|
252
391
|
(k) => k.kty === "RSA" && k.alg === "RS256" && k.use === "sig" && (kid ? k.kid === kid : true)
|
|
253
392
|
);
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
393
|
+
const verifyAny = async (keys) => {
|
|
394
|
+
for (const key of keys) {
|
|
395
|
+
if (await verifyRs256Signature(idToken, key)) return true;
|
|
396
|
+
}
|
|
397
|
+
return false;
|
|
398
|
+
};
|
|
399
|
+
let candidates = matchCandidates(jwks);
|
|
400
|
+
let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
|
|
401
|
+
if (candidates.length === 0 || !validSig) {
|
|
402
|
+
const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });
|
|
403
|
+
if (freshJwks) {
|
|
404
|
+
candidates = matchCandidates(freshJwks);
|
|
405
|
+
validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (candidates.length === 0) {
|
|
409
|
+
throw new SsoError("id_token_invalid_signature", "JWKS \u4E2D\u672A\u627E\u5230\u5339\u914D\u7684 RS256 \u516C\u94A5");
|
|
410
|
+
}
|
|
411
|
+
if (!validSig) {
|
|
412
|
+
throw new SsoError("id_token_invalid_signature", "ID Token \u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25");
|
|
413
|
+
}
|
|
257
414
|
}
|
|
258
415
|
if (alg === "HS256") {
|
|
259
|
-
|
|
260
|
-
|
|
416
|
+
if (rejectHs256WhenRs256Available) {
|
|
417
|
+
const jwks = await fetchJwks(baseUrl);
|
|
418
|
+
const hasRs256 = jwks?.keys?.some((k) => k.alg === "RS256" && k.use === "sig");
|
|
419
|
+
if (hasRs256) {
|
|
420
|
+
throw new SsoError("id_token_unsupported_alg", "SSO \u4E2D\u5FC3\u5DF2\u914D\u7F6E RS256\uFF0C\u62D2\u7EDD HS256 ID Token");
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
throw new SsoError(
|
|
424
|
+
"id_token_hs256_unsupported",
|
|
425
|
+
"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
426
|
);
|
|
262
427
|
}
|
|
263
428
|
const payload = decodeJwtPayload(idToken);
|
|
264
|
-
if (!payload)
|
|
265
|
-
|
|
266
|
-
|
|
429
|
+
if (!payload) {
|
|
430
|
+
throw new SsoError("id_token_invalid", "ID Token payload \u89E3\u6790\u5931\u8D25");
|
|
431
|
+
}
|
|
432
|
+
const tokenIssuer = typeof payload.iss === "string" ? normalizeIssuer(payload.iss) : "";
|
|
267
433
|
if (tokenIssuer !== normalizedIssuer) {
|
|
268
|
-
throw new
|
|
434
|
+
throw new SsoError("id_token_issuer_mismatch", "ID Token issuer \u4E0D\u5339\u914D");
|
|
269
435
|
}
|
|
270
436
|
const aud = payload.aud;
|
|
271
|
-
const
|
|
272
|
-
if (!
|
|
273
|
-
throw new
|
|
437
|
+
const audList = Array.isArray(aud) ? aud : typeof aud === "string" ? [aud] : [];
|
|
438
|
+
if (!audList.includes(expectedClientId)) {
|
|
439
|
+
throw new SsoError("id_token_audience_mismatch", "ID Token audience \u4E0D\u5339\u914D");
|
|
440
|
+
}
|
|
441
|
+
if (audList.length > 1 && payload.azp !== expectedClientId) {
|
|
442
|
+
throw new SsoError("id_token_audience_mismatch", "ID Token \u591A audience \u65F6 azp \u5FC5\u987B\u4E3A\u5F53\u524D client");
|
|
443
|
+
}
|
|
444
|
+
if (typeof payload.exp !== "number") {
|
|
445
|
+
throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 exp \u58F0\u660E");
|
|
446
|
+
}
|
|
447
|
+
if (Date.now() >= payload.exp * 1e3) {
|
|
448
|
+
throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
|
|
274
449
|
}
|
|
275
|
-
if (typeof payload.
|
|
276
|
-
throw new
|
|
450
|
+
if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
|
|
451
|
+
throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
|
|
277
452
|
}
|
|
278
453
|
if (typeof payload.sub !== "string" || !payload.sub) {
|
|
279
|
-
throw new
|
|
454
|
+
throw new SsoError("id_token_missing_sub", "ID Token \u7F3A\u5C11 sub");
|
|
280
455
|
}
|
|
281
456
|
if (typeof payload.at_hash === "string" && payload.at_hash) {
|
|
282
457
|
const actual = await computeAtHash(accessToken);
|
|
283
|
-
if (actual
|
|
284
|
-
throw new
|
|
458
|
+
if (!timingSafeEqualString(actual, payload.at_hash)) {
|
|
459
|
+
throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
|
|
285
460
|
}
|
|
286
461
|
}
|
|
462
|
+
return { sub: payload.sub };
|
|
287
463
|
}
|
|
464
|
+
|
|
465
|
+
// src/next/callback.ts
|
|
288
466
|
function createCallbackRouteHandler(config) {
|
|
289
467
|
const {
|
|
290
468
|
clientId,
|
|
@@ -360,7 +538,7 @@ function createCallbackRouteHandler(config) {
|
|
|
360
538
|
if (clientSecret) {
|
|
361
539
|
body.set("client_secret", clientSecret);
|
|
362
540
|
}
|
|
363
|
-
let res;
|
|
541
|
+
let res = null;
|
|
364
542
|
let lastError;
|
|
365
543
|
const maxRetries = 1;
|
|
366
544
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
@@ -435,9 +613,24 @@ function createCallbackRouteHandler(config) {
|
|
|
435
613
|
...getHostCookieOptions(refreshMaxAge)
|
|
436
614
|
});
|
|
437
615
|
if (tokenData.id_token) {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
616
|
+
let idTokenMaxAge = 3600;
|
|
617
|
+
try {
|
|
618
|
+
const parts = tokenData.id_token.split(".");
|
|
619
|
+
if (parts.length === 3) {
|
|
620
|
+
const payload = JSON.parse(
|
|
621
|
+
Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")
|
|
622
|
+
);
|
|
623
|
+
if (payload.exp && typeof payload.exp === "number") {
|
|
624
|
+
idTokenMaxAge = payload.exp - Math.floor(Date.now() / 1e3);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
} catch {
|
|
628
|
+
}
|
|
629
|
+
if (idTokenMaxAge > 0) {
|
|
630
|
+
response.cookies.set(idTokenCookieName, tokenData.id_token, {
|
|
631
|
+
...getHostCookieOptions(idTokenMaxAge)
|
|
632
|
+
});
|
|
633
|
+
}
|
|
441
634
|
}
|
|
442
635
|
response.cookies.set(stateCookieName, "", getHostCookieOptions(0));
|
|
443
636
|
response.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0));
|
|
@@ -450,9 +643,9 @@ function createCallbackRouteHandler(config) {
|
|
|
450
643
|
// src/next/logout.ts
|
|
451
644
|
import { NextResponse as NextResponse3 } from "next/server";
|
|
452
645
|
async function fetchDiscovery(ssoBaseUrl) {
|
|
646
|
+
const controller = new AbortController();
|
|
647
|
+
const timeoutId = setTimeout(() => controller.abort(), 5e3);
|
|
453
648
|
try {
|
|
454
|
-
const controller = new AbortController();
|
|
455
|
-
setTimeout(() => controller.abort(), 5e3);
|
|
456
649
|
const res = await fetch(
|
|
457
650
|
`${ssoBaseUrl}/api/oauth/.well-known/openid-configuration`,
|
|
458
651
|
{ signal: controller.signal }
|
|
@@ -461,8 +654,24 @@ async function fetchDiscovery(ssoBaseUrl) {
|
|
|
461
654
|
return await res.json();
|
|
462
655
|
} catch {
|
|
463
656
|
return null;
|
|
657
|
+
} finally {
|
|
658
|
+
clearTimeout(timeoutId);
|
|
464
659
|
}
|
|
465
660
|
}
|
|
661
|
+
function generateRandomString2(length) {
|
|
662
|
+
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
|
|
663
|
+
const maxValid = Math.floor(256 / chars.length) * chars.length;
|
|
664
|
+
let result = "";
|
|
665
|
+
while (result.length < length) {
|
|
666
|
+
const array = new Uint8Array(length * 2);
|
|
667
|
+
crypto.getRandomValues(array);
|
|
668
|
+
for (let i = 0; i < array.length && result.length < length; i++) {
|
|
669
|
+
if (array[i] >= maxValid) continue;
|
|
670
|
+
result += chars[array[i] % chars.length];
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return result;
|
|
674
|
+
}
|
|
466
675
|
function createLogoutRouteHandler(config) {
|
|
467
676
|
const {
|
|
468
677
|
clientId,
|
|
@@ -477,10 +686,24 @@ function createLogoutRouteHandler(config) {
|
|
|
477
686
|
stateCookieName = DEFAULT_STATE_COOKIE_NAME,
|
|
478
687
|
returnUrlCookieName = DEFAULT_RETURN_COOKIE_NAME,
|
|
479
688
|
verifierCookieName = DEFAULT_VERIFIER_COOKIE_NAME,
|
|
480
|
-
callbackPath = "/api/auth/callback"
|
|
689
|
+
callbackPath = "/api/auth/callback",
|
|
690
|
+
logoutStateCookieName = DEFAULT_LOGOUT_STATE_COOKIE_NAME
|
|
481
691
|
} = config;
|
|
482
692
|
const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
|
|
483
|
-
return async function
|
|
693
|
+
return async function handler(request) {
|
|
694
|
+
const returnedState = request.nextUrl.searchParams.get("state");
|
|
695
|
+
if (returnedState) {
|
|
696
|
+
const savedState = request.cookies.get(logoutStateCookieName)?.value;
|
|
697
|
+
if (!savedState || savedState !== returnedState) {
|
|
698
|
+
return NextResponse3.json(
|
|
699
|
+
{ error: "invalid_request", error_description: "Logout state \u4E0D\u5339\u914D" },
|
|
700
|
+
{ status: 400 }
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
const res = NextResponse3.redirect(request.nextUrl.origin + "/");
|
|
704
|
+
res.cookies.set(logoutStateCookieName, "", getHostCookieOptions(0));
|
|
705
|
+
return res;
|
|
706
|
+
}
|
|
484
707
|
const refreshToken = request.cookies.get(refreshTokenCookieName)?.value;
|
|
485
708
|
const idTokenHint = request.cookies.get(idTokenCookieName)?.value;
|
|
486
709
|
if (refreshToken) {
|
|
@@ -525,7 +748,11 @@ function createLogoutRouteHandler(config) {
|
|
|
525
748
|
if (idTokenHint) {
|
|
526
749
|
logoutUrl.searchParams.set("id_token_hint", idTokenHint);
|
|
527
750
|
}
|
|
528
|
-
|
|
751
|
+
const logoutState = generateRandomString2(32);
|
|
752
|
+
logoutUrl.searchParams.set("state", logoutState);
|
|
753
|
+
const res = clearCookies(NextResponse3.redirect(logoutUrl.toString()));
|
|
754
|
+
res.cookies.set(logoutStateCookieName, logoutState, getHostCookieOptions(600));
|
|
755
|
+
return res;
|
|
529
756
|
}
|
|
530
757
|
return clearCookies(
|
|
531
758
|
NextResponse3.redirect(request.nextUrl.origin + "/")
|
|
@@ -534,6 +761,8 @@ function createLogoutRouteHandler(config) {
|
|
|
534
761
|
}
|
|
535
762
|
export {
|
|
536
763
|
DEFAULT_ACCESS_TOKEN_COOKIE_NAME,
|
|
764
|
+
DEFAULT_ID_TOKEN_COOKIE_NAME,
|
|
765
|
+
DEFAULT_LOGOUT_STATE_COOKIE_NAME,
|
|
537
766
|
DEFAULT_REFRESH_TOKEN_COOKIE_NAME,
|
|
538
767
|
DEFAULT_RETURN_COOKIE_NAME,
|
|
539
768
|
DEFAULT_STATE_COOKIE_NAME,
|