@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.js
CHANGED
|
@@ -21,6 +21,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
21
21
|
var next_exports = {};
|
|
22
22
|
__export(next_exports, {
|
|
23
23
|
DEFAULT_ACCESS_TOKEN_COOKIE_NAME: () => DEFAULT_ACCESS_TOKEN_COOKIE_NAME,
|
|
24
|
+
DEFAULT_ID_TOKEN_COOKIE_NAME: () => DEFAULT_ID_TOKEN_COOKIE_NAME,
|
|
25
|
+
DEFAULT_LOGOUT_STATE_COOKIE_NAME: () => DEFAULT_LOGOUT_STATE_COOKIE_NAME,
|
|
24
26
|
DEFAULT_REFRESH_TOKEN_COOKIE_NAME: () => DEFAULT_REFRESH_TOKEN_COOKIE_NAME,
|
|
25
27
|
DEFAULT_RETURN_COOKIE_NAME: () => DEFAULT_RETURN_COOKIE_NAME,
|
|
26
28
|
DEFAULT_STATE_COOKIE_NAME: () => DEFAULT_STATE_COOKIE_NAME,
|
|
@@ -43,6 +45,7 @@ var DEFAULT_ID_TOKEN_COOKIE_NAME = "__Host-nihplod_sso_id";
|
|
|
43
45
|
var DEFAULT_STATE_COOKIE_NAME = "__Host-nihplod_sso_state";
|
|
44
46
|
var DEFAULT_RETURN_COOKIE_NAME = "__Host-nihplod_sso_return";
|
|
45
47
|
var DEFAULT_VERIFIER_COOKIE_NAME = "__Secure-nihplod_sso_verifier";
|
|
48
|
+
var DEFAULT_LOGOUT_STATE_COOKIE_NAME = "__Host-nihplod_sso_logout_state";
|
|
46
49
|
function getHostCookieOptions(maxAge) {
|
|
47
50
|
return {
|
|
48
51
|
httpOnly: true,
|
|
@@ -96,7 +99,43 @@ function matchesPath(pathname, paths) {
|
|
|
96
99
|
return false;
|
|
97
100
|
});
|
|
98
101
|
}
|
|
102
|
+
var introspectionCache = /* @__PURE__ */ new Map();
|
|
103
|
+
var INTROSPECT_CACHE_TTL_MS = 3e4;
|
|
104
|
+
var INTROSPECT_CACHE_MAX_ENTRIES = 500;
|
|
105
|
+
async function introspectCacheKey(token, clientId) {
|
|
106
|
+
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
|
|
107
|
+
const hex = Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
108
|
+
return `${hex}|${clientId}`;
|
|
109
|
+
}
|
|
110
|
+
function introspectCacheGet(key) {
|
|
111
|
+
const entry = introspectionCache.get(key);
|
|
112
|
+
if (!entry) return null;
|
|
113
|
+
if (entry.until <= Date.now()) {
|
|
114
|
+
introspectionCache.delete(key);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
introspectionCache.delete(key);
|
|
118
|
+
introspectionCache.set(key, entry);
|
|
119
|
+
return entry;
|
|
120
|
+
}
|
|
121
|
+
function introspectCacheSet(key, active) {
|
|
122
|
+
const now = Date.now();
|
|
123
|
+
for (const [k, v] of introspectionCache) {
|
|
124
|
+
if (v.until <= now) introspectionCache.delete(k);
|
|
125
|
+
}
|
|
126
|
+
while (introspectionCache.size >= INTROSPECT_CACHE_MAX_ENTRIES) {
|
|
127
|
+
const oldest = introspectionCache.keys().next();
|
|
128
|
+
if (oldest.done) break;
|
|
129
|
+
introspectionCache.delete(oldest.value);
|
|
130
|
+
}
|
|
131
|
+
introspectionCache.set(key, { active, until: now + INTROSPECT_CACHE_TTL_MS });
|
|
132
|
+
}
|
|
99
133
|
async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret) {
|
|
134
|
+
const cacheKey = await introspectCacheKey(token, clientId);
|
|
135
|
+
const cached = introspectCacheGet(cacheKey);
|
|
136
|
+
if (cached) {
|
|
137
|
+
return cached.active;
|
|
138
|
+
}
|
|
100
139
|
try {
|
|
101
140
|
const body = new URLSearchParams({
|
|
102
141
|
token,
|
|
@@ -111,9 +150,17 @@ async function introspectAccessToken(token, ssoBaseUrl, clientId, clientSecret)
|
|
|
111
150
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
112
151
|
body: body.toString()
|
|
113
152
|
});
|
|
114
|
-
if (!res.ok)
|
|
153
|
+
if (!res.ok) {
|
|
154
|
+
if (res.status !== 401 && res.status !== 403) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
introspectCacheSet(cacheKey, false);
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
115
160
|
const data = await res.json();
|
|
116
|
-
|
|
161
|
+
const active = data.active === true;
|
|
162
|
+
introspectCacheSet(cacheKey, active);
|
|
163
|
+
return active;
|
|
117
164
|
} catch {
|
|
118
165
|
return false;
|
|
119
166
|
}
|
|
@@ -128,12 +175,25 @@ function createSsoMiddleware(config) {
|
|
|
128
175
|
publicPaths = [],
|
|
129
176
|
callbackPath = "/api/auth/callback",
|
|
130
177
|
ssoCookieName = "__Host-user_token",
|
|
178
|
+
validateSsoCookie = true,
|
|
131
179
|
accessTokenCookieName = DEFAULT_ACCESS_TOKEN_COOKIE_NAME,
|
|
132
180
|
stateCookieName = DEFAULT_STATE_COOKIE_NAME,
|
|
133
181
|
returnUrlCookieName = DEFAULT_RETURN_COOKIE_NAME,
|
|
134
182
|
verifierCookieName = DEFAULT_VERIFIER_COOKIE_NAME
|
|
135
183
|
} = config;
|
|
136
184
|
const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
|
|
185
|
+
if (process.env.NODE_ENV !== "production") {
|
|
186
|
+
if (!validateSsoCookie) {
|
|
187
|
+
console.warn(
|
|
188
|
+
"[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"
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
if (!clientSecret) {
|
|
192
|
+
console.warn(
|
|
193
|
+
"[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"
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
137
197
|
return async function ssoMiddleware(request) {
|
|
138
198
|
const { pathname } = request.nextUrl;
|
|
139
199
|
if (pathname.startsWith("/_next/") || pathname.startsWith("/favicon.ico") || pathname.match(/\.(ico|png|jpg|jpeg|svg|css|js|woff2?)$/)) {
|
|
@@ -148,7 +208,19 @@ function createSsoMiddleware(config) {
|
|
|
148
208
|
}
|
|
149
209
|
const ssoSession = request.cookies.get(ssoCookieName);
|
|
150
210
|
if (ssoSession?.value) {
|
|
151
|
-
|
|
211
|
+
if (validateSsoCookie) {
|
|
212
|
+
const tokenActive = await introspectAccessToken(
|
|
213
|
+
ssoSession.value,
|
|
214
|
+
normalizedBase,
|
|
215
|
+
clientId,
|
|
216
|
+
clientSecret
|
|
217
|
+
);
|
|
218
|
+
if (tokenActive) {
|
|
219
|
+
return import_server.NextResponse.next();
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
return import_server.NextResponse.next();
|
|
223
|
+
}
|
|
152
224
|
}
|
|
153
225
|
const accessTokenCookie = request.cookies.get(accessTokenCookieName);
|
|
154
226
|
if (accessTokenCookie?.value) {
|
|
@@ -178,7 +250,8 @@ function createSsoMiddleware(config) {
|
|
|
178
250
|
const response = import_server.NextResponse.redirect(loginUrl);
|
|
179
251
|
response.cookies.set(stateCookieName, state, getHostCookieOptions(600));
|
|
180
252
|
response.cookies.set(verifierCookieName, verifier, getSecureCookieOptions(600, callbackPath));
|
|
181
|
-
|
|
253
|
+
const safeReturnUrl = (request.nextUrl.pathname + request.nextUrl.search).slice(0, 2048);
|
|
254
|
+
response.cookies.set(returnUrlCookieName, safeReturnUrl, getHostCookieOptions(600));
|
|
182
255
|
if (accessTokenCookie?.value) {
|
|
183
256
|
response.cookies.set(accessTokenCookieName, "", getHostCookieOptions(0));
|
|
184
257
|
}
|
|
@@ -188,6 +261,19 @@ function createSsoMiddleware(config) {
|
|
|
188
261
|
|
|
189
262
|
// src/next/callback.ts
|
|
190
263
|
var import_server2 = require("next/server");
|
|
264
|
+
|
|
265
|
+
// src/core/errors.ts
|
|
266
|
+
var SsoError = class extends Error {
|
|
267
|
+
constructor(code, description, cause) {
|
|
268
|
+
super(`[SSO SDK] ${code}: ${description}`);
|
|
269
|
+
this.name = "SsoError";
|
|
270
|
+
this.code = code;
|
|
271
|
+
this.description = description;
|
|
272
|
+
this.cause = cause;
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// src/core/security.ts
|
|
191
277
|
function isTrustedReturnUrl(url, currentOrigin) {
|
|
192
278
|
if (!url) return false;
|
|
193
279
|
if (url.startsWith("/") && !url.startsWith("//")) return true;
|
|
@@ -197,6 +283,23 @@ function isTrustedReturnUrl(url, currentOrigin) {
|
|
|
197
283
|
return false;
|
|
198
284
|
}
|
|
199
285
|
}
|
|
286
|
+
function timingSafeEqualString(a, b) {
|
|
287
|
+
const ba = new TextEncoder().encode(a);
|
|
288
|
+
const bb = new TextEncoder().encode(b);
|
|
289
|
+
if (ba.length === 0 || bb.length === 0) return ba.length === bb.length;
|
|
290
|
+
const len = Math.max(ba.length, bb.length);
|
|
291
|
+
let diff = ba.length ^ bb.length;
|
|
292
|
+
for (let i = 0; i < len; i++) {
|
|
293
|
+
diff |= ba[i % ba.length] ^ bb[i % bb.length];
|
|
294
|
+
}
|
|
295
|
+
return diff === 0;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// src/core/id-token.ts
|
|
299
|
+
var _crypto = typeof crypto !== "undefined" && crypto.subtle ? crypto : null;
|
|
300
|
+
function getCrypto() {
|
|
301
|
+
return _crypto ?? null;
|
|
302
|
+
}
|
|
200
303
|
function base64UrlDecode(input) {
|
|
201
304
|
const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
202
305
|
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
|
|
@@ -227,11 +330,40 @@ function decodeJwtHeader(token) {
|
|
|
227
330
|
return null;
|
|
228
331
|
}
|
|
229
332
|
}
|
|
230
|
-
|
|
333
|
+
function normalizeIssuer(url) {
|
|
334
|
+
return url.replace(/\/+$/, "");
|
|
335
|
+
}
|
|
336
|
+
var cachedJwks = null;
|
|
337
|
+
var cachedDiscovery = null;
|
|
338
|
+
var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
339
|
+
async function fetchDiscoveryDoc(baseUrl) {
|
|
340
|
+
const now = Date.now();
|
|
341
|
+
if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
|
|
342
|
+
return cachedDiscovery.doc;
|
|
343
|
+
}
|
|
231
344
|
try {
|
|
232
|
-
const res = await fetch(`${baseUrl}/api/oauth/
|
|
345
|
+
const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`);
|
|
346
|
+
const doc = res.ok ? await res.json() : null;
|
|
347
|
+
cachedDiscovery = { baseUrl, doc, fetchedAt: now };
|
|
348
|
+
return doc;
|
|
349
|
+
} catch {
|
|
350
|
+
cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
async function fetchJwks(baseUrl, options = {}) {
|
|
355
|
+
const now = Date.now();
|
|
356
|
+
if (!options.forceRefresh && cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
|
|
357
|
+
return cachedJwks.jwks;
|
|
358
|
+
}
|
|
359
|
+
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
360
|
+
const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
|
|
361
|
+
try {
|
|
362
|
+
const res = await fetch(jwksUri, options.forceRefresh ? { cache: "no-cache" } : void 0);
|
|
233
363
|
if (!res.ok) return null;
|
|
234
|
-
|
|
364
|
+
const jwks = await res.json();
|
|
365
|
+
cachedJwks = { baseUrl, jwks, fetchedAt: now };
|
|
366
|
+
return jwks;
|
|
235
367
|
} catch {
|
|
236
368
|
return null;
|
|
237
369
|
}
|
|
@@ -240,7 +372,9 @@ async function verifyRs256Signature(token, jwk) {
|
|
|
240
372
|
try {
|
|
241
373
|
const [headerB64, payloadB64, signature] = token.split(".");
|
|
242
374
|
if (!signature || !jwk.n || !jwk.e) return false;
|
|
243
|
-
const
|
|
375
|
+
const c = getCrypto();
|
|
376
|
+
if (!c) return false;
|
|
377
|
+
const cryptoKey = await c.subtle.importKey(
|
|
244
378
|
"jwk",
|
|
245
379
|
{ kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: false },
|
|
246
380
|
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
@@ -249,7 +383,7 @@ async function verifyRs256Signature(token, jwk) {
|
|
|
249
383
|
);
|
|
250
384
|
const signatureBytes = base64UrlDecode(signature);
|
|
251
385
|
const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
|
|
252
|
-
return await
|
|
386
|
+
return await c.subtle.verify(
|
|
253
387
|
"RSASSA-PKCS1-v1_5",
|
|
254
388
|
cryptoKey,
|
|
255
389
|
signatureBytes,
|
|
@@ -260,10 +394,9 @@ async function verifyRs256Signature(token, jwk) {
|
|
|
260
394
|
}
|
|
261
395
|
}
|
|
262
396
|
async function computeAtHash(accessToken) {
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
);
|
|
397
|
+
const c = getCrypto();
|
|
398
|
+
if (!c) return "";
|
|
399
|
+
const hash = await c.subtle.digest("SHA-256", new TextEncoder().encode(accessToken));
|
|
267
400
|
const bytes = new Uint8Array(hash);
|
|
268
401
|
const half = bytes.slice(0, bytes.length / 2);
|
|
269
402
|
let binary = "";
|
|
@@ -272,54 +405,101 @@ async function computeAtHash(accessToken) {
|
|
|
272
405
|
}
|
|
273
406
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
274
407
|
}
|
|
275
|
-
async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId) {
|
|
408
|
+
async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
|
|
409
|
+
const { rejectHs256WhenRs256Available = true } = options;
|
|
276
410
|
const header = decodeJwtHeader(idToken);
|
|
277
|
-
if (!header)
|
|
411
|
+
if (!header) {
|
|
412
|
+
throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
|
|
413
|
+
}
|
|
278
414
|
const alg = header.alg;
|
|
279
415
|
if (typeof alg !== "string" || alg !== "RS256" && alg !== "HS256") {
|
|
280
|
-
throw new
|
|
416
|
+
throw new SsoError("id_token_unsupported_alg", `\u4E0D\u652F\u6301\u7684 ID Token \u7B7E\u540D\u7B97\u6CD5: ${alg}`);
|
|
281
417
|
}
|
|
418
|
+
const baseUrl = normalizeIssuer(expectedIssuer);
|
|
419
|
+
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
420
|
+
const normalizedIssuer = normalizeIssuer(discovery?.issuer || baseUrl);
|
|
282
421
|
if (alg === "RS256") {
|
|
283
|
-
const jwks = await fetchJwks(
|
|
284
|
-
if (!jwks)
|
|
422
|
+
const jwks = await fetchJwks(baseUrl);
|
|
423
|
+
if (!jwks) {
|
|
424
|
+
throw new SsoError("id_token_invalid_signature", "\u65E0\u6CD5\u83B7\u53D6 JWKS \u9A8C\u8BC1 ID Token \u7B7E\u540D");
|
|
425
|
+
}
|
|
285
426
|
const kid = typeof header.kid === "string" ? header.kid : void 0;
|
|
286
|
-
const
|
|
427
|
+
const matchCandidates = (set) => set.keys.filter(
|
|
287
428
|
(k) => k.kty === "RSA" && k.alg === "RS256" && k.use === "sig" && (kid ? k.kid === kid : true)
|
|
288
429
|
);
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
430
|
+
const verifyAny = async (keys) => {
|
|
431
|
+
for (const key of keys) {
|
|
432
|
+
if (await verifyRs256Signature(idToken, key)) return true;
|
|
433
|
+
}
|
|
434
|
+
return false;
|
|
435
|
+
};
|
|
436
|
+
let candidates = matchCandidates(jwks);
|
|
437
|
+
let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
|
|
438
|
+
if (candidates.length === 0 || !validSig) {
|
|
439
|
+
const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });
|
|
440
|
+
if (freshJwks) {
|
|
441
|
+
candidates = matchCandidates(freshJwks);
|
|
442
|
+
validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (candidates.length === 0) {
|
|
446
|
+
throw new SsoError("id_token_invalid_signature", "JWKS \u4E2D\u672A\u627E\u5230\u5339\u914D\u7684 RS256 \u516C\u94A5");
|
|
447
|
+
}
|
|
448
|
+
if (!validSig) {
|
|
449
|
+
throw new SsoError("id_token_invalid_signature", "ID Token \u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25");
|
|
450
|
+
}
|
|
292
451
|
}
|
|
293
452
|
if (alg === "HS256") {
|
|
294
|
-
|
|
295
|
-
|
|
453
|
+
if (rejectHs256WhenRs256Available) {
|
|
454
|
+
const jwks = await fetchJwks(baseUrl);
|
|
455
|
+
const hasRs256 = jwks?.keys?.some((k) => k.alg === "RS256" && k.use === "sig");
|
|
456
|
+
if (hasRs256) {
|
|
457
|
+
throw new SsoError("id_token_unsupported_alg", "SSO \u4E2D\u5FC3\u5DF2\u914D\u7F6E RS256\uFF0C\u62D2\u7EDD HS256 ID Token");
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
throw new SsoError(
|
|
461
|
+
"id_token_hs256_unsupported",
|
|
462
|
+
"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"
|
|
296
463
|
);
|
|
297
464
|
}
|
|
298
465
|
const payload = decodeJwtPayload(idToken);
|
|
299
|
-
if (!payload)
|
|
300
|
-
|
|
301
|
-
|
|
466
|
+
if (!payload) {
|
|
467
|
+
throw new SsoError("id_token_invalid", "ID Token payload \u89E3\u6790\u5931\u8D25");
|
|
468
|
+
}
|
|
469
|
+
const tokenIssuer = typeof payload.iss === "string" ? normalizeIssuer(payload.iss) : "";
|
|
302
470
|
if (tokenIssuer !== normalizedIssuer) {
|
|
303
|
-
throw new
|
|
471
|
+
throw new SsoError("id_token_issuer_mismatch", "ID Token issuer \u4E0D\u5339\u914D");
|
|
304
472
|
}
|
|
305
473
|
const aud = payload.aud;
|
|
306
|
-
const
|
|
307
|
-
if (!
|
|
308
|
-
throw new
|
|
474
|
+
const audList = Array.isArray(aud) ? aud : typeof aud === "string" ? [aud] : [];
|
|
475
|
+
if (!audList.includes(expectedClientId)) {
|
|
476
|
+
throw new SsoError("id_token_audience_mismatch", "ID Token audience \u4E0D\u5339\u914D");
|
|
477
|
+
}
|
|
478
|
+
if (audList.length > 1 && payload.azp !== expectedClientId) {
|
|
479
|
+
throw new SsoError("id_token_audience_mismatch", "ID Token \u591A audience \u65F6 azp \u5FC5\u987B\u4E3A\u5F53\u524D client");
|
|
480
|
+
}
|
|
481
|
+
if (typeof payload.exp !== "number") {
|
|
482
|
+
throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 exp \u58F0\u660E");
|
|
483
|
+
}
|
|
484
|
+
if (Date.now() >= payload.exp * 1e3) {
|
|
485
|
+
throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
|
|
309
486
|
}
|
|
310
|
-
if (typeof payload.
|
|
311
|
-
throw new
|
|
487
|
+
if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
|
|
488
|
+
throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
|
|
312
489
|
}
|
|
313
490
|
if (typeof payload.sub !== "string" || !payload.sub) {
|
|
314
|
-
throw new
|
|
491
|
+
throw new SsoError("id_token_missing_sub", "ID Token \u7F3A\u5C11 sub");
|
|
315
492
|
}
|
|
316
493
|
if (typeof payload.at_hash === "string" && payload.at_hash) {
|
|
317
494
|
const actual = await computeAtHash(accessToken);
|
|
318
|
-
if (actual
|
|
319
|
-
throw new
|
|
495
|
+
if (!timingSafeEqualString(actual, payload.at_hash)) {
|
|
496
|
+
throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
|
|
320
497
|
}
|
|
321
498
|
}
|
|
499
|
+
return { sub: payload.sub };
|
|
322
500
|
}
|
|
501
|
+
|
|
502
|
+
// src/next/callback.ts
|
|
323
503
|
function createCallbackRouteHandler(config) {
|
|
324
504
|
const {
|
|
325
505
|
clientId,
|
|
@@ -395,7 +575,7 @@ function createCallbackRouteHandler(config) {
|
|
|
395
575
|
if (clientSecret) {
|
|
396
576
|
body.set("client_secret", clientSecret);
|
|
397
577
|
}
|
|
398
|
-
let res;
|
|
578
|
+
let res = null;
|
|
399
579
|
let lastError;
|
|
400
580
|
const maxRetries = 1;
|
|
401
581
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
@@ -470,9 +650,24 @@ function createCallbackRouteHandler(config) {
|
|
|
470
650
|
...getHostCookieOptions(refreshMaxAge)
|
|
471
651
|
});
|
|
472
652
|
if (tokenData.id_token) {
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
653
|
+
let idTokenMaxAge = 3600;
|
|
654
|
+
try {
|
|
655
|
+
const parts = tokenData.id_token.split(".");
|
|
656
|
+
if (parts.length === 3) {
|
|
657
|
+
const payload = JSON.parse(
|
|
658
|
+
Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")
|
|
659
|
+
);
|
|
660
|
+
if (payload.exp && typeof payload.exp === "number") {
|
|
661
|
+
idTokenMaxAge = payload.exp - Math.floor(Date.now() / 1e3);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
} catch {
|
|
665
|
+
}
|
|
666
|
+
if (idTokenMaxAge > 0) {
|
|
667
|
+
response.cookies.set(idTokenCookieName, tokenData.id_token, {
|
|
668
|
+
...getHostCookieOptions(idTokenMaxAge)
|
|
669
|
+
});
|
|
670
|
+
}
|
|
476
671
|
}
|
|
477
672
|
response.cookies.set(stateCookieName, "", getHostCookieOptions(0));
|
|
478
673
|
response.cookies.set(returnUrlCookieName, "", getHostCookieOptions(0));
|
|
@@ -485,9 +680,9 @@ function createCallbackRouteHandler(config) {
|
|
|
485
680
|
// src/next/logout.ts
|
|
486
681
|
var import_server3 = require("next/server");
|
|
487
682
|
async function fetchDiscovery(ssoBaseUrl) {
|
|
683
|
+
const controller = new AbortController();
|
|
684
|
+
const timeoutId = setTimeout(() => controller.abort(), 5e3);
|
|
488
685
|
try {
|
|
489
|
-
const controller = new AbortController();
|
|
490
|
-
setTimeout(() => controller.abort(), 5e3);
|
|
491
686
|
const res = await fetch(
|
|
492
687
|
`${ssoBaseUrl}/api/oauth/.well-known/openid-configuration`,
|
|
493
688
|
{ signal: controller.signal }
|
|
@@ -496,8 +691,24 @@ async function fetchDiscovery(ssoBaseUrl) {
|
|
|
496
691
|
return await res.json();
|
|
497
692
|
} catch {
|
|
498
693
|
return null;
|
|
694
|
+
} finally {
|
|
695
|
+
clearTimeout(timeoutId);
|
|
499
696
|
}
|
|
500
697
|
}
|
|
698
|
+
function generateRandomString2(length) {
|
|
699
|
+
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
|
|
700
|
+
const maxValid = Math.floor(256 / chars.length) * chars.length;
|
|
701
|
+
let result = "";
|
|
702
|
+
while (result.length < length) {
|
|
703
|
+
const array = new Uint8Array(length * 2);
|
|
704
|
+
crypto.getRandomValues(array);
|
|
705
|
+
for (let i = 0; i < array.length && result.length < length; i++) {
|
|
706
|
+
if (array[i] >= maxValid) continue;
|
|
707
|
+
result += chars[array[i] % chars.length];
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return result;
|
|
711
|
+
}
|
|
501
712
|
function createLogoutRouteHandler(config) {
|
|
502
713
|
const {
|
|
503
714
|
clientId,
|
|
@@ -512,10 +723,24 @@ function createLogoutRouteHandler(config) {
|
|
|
512
723
|
stateCookieName = DEFAULT_STATE_COOKIE_NAME,
|
|
513
724
|
returnUrlCookieName = DEFAULT_RETURN_COOKIE_NAME,
|
|
514
725
|
verifierCookieName = DEFAULT_VERIFIER_COOKIE_NAME,
|
|
515
|
-
callbackPath = "/api/auth/callback"
|
|
726
|
+
callbackPath = "/api/auth/callback",
|
|
727
|
+
logoutStateCookieName = DEFAULT_LOGOUT_STATE_COOKIE_NAME
|
|
516
728
|
} = config;
|
|
517
729
|
const normalizedBase = ssoBaseUrl.replace(/\/+$/, "");
|
|
518
|
-
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 import_server3.NextResponse.json(
|
|
736
|
+
{ error: "invalid_request", error_description: "Logout state \u4E0D\u5339\u914D" },
|
|
737
|
+
{ status: 400 }
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
const res = import_server3.NextResponse.redirect(request.nextUrl.origin + "/");
|
|
741
|
+
res.cookies.set(logoutStateCookieName, "", getHostCookieOptions(0));
|
|
742
|
+
return res;
|
|
743
|
+
}
|
|
519
744
|
const refreshToken = request.cookies.get(refreshTokenCookieName)?.value;
|
|
520
745
|
const idTokenHint = request.cookies.get(idTokenCookieName)?.value;
|
|
521
746
|
if (refreshToken) {
|
|
@@ -560,7 +785,11 @@ function createLogoutRouteHandler(config) {
|
|
|
560
785
|
if (idTokenHint) {
|
|
561
786
|
logoutUrl.searchParams.set("id_token_hint", idTokenHint);
|
|
562
787
|
}
|
|
563
|
-
|
|
788
|
+
const logoutState = generateRandomString2(32);
|
|
789
|
+
logoutUrl.searchParams.set("state", logoutState);
|
|
790
|
+
const res = clearCookies(import_server3.NextResponse.redirect(logoutUrl.toString()));
|
|
791
|
+
res.cookies.set(logoutStateCookieName, logoutState, getHostCookieOptions(600));
|
|
792
|
+
return res;
|
|
564
793
|
}
|
|
565
794
|
return clearCookies(
|
|
566
795
|
import_server3.NextResponse.redirect(request.nextUrl.origin + "/")
|
|
@@ -570,6 +799,8 @@ function createLogoutRouteHandler(config) {
|
|
|
570
799
|
// Annotate the CommonJS export names for ESM import in node:
|
|
571
800
|
0 && (module.exports = {
|
|
572
801
|
DEFAULT_ACCESS_TOKEN_COOKIE_NAME,
|
|
802
|
+
DEFAULT_ID_TOKEN_COOKIE_NAME,
|
|
803
|
+
DEFAULT_LOGOUT_STATE_COOKIE_NAME,
|
|
573
804
|
DEFAULT_REFRESH_TOKEN_COOKIE_NAME,
|
|
574
805
|
DEFAULT_RETURN_COOKIE_NAME,
|
|
575
806
|
DEFAULT_STATE_COOKIE_NAME,
|