@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/index.js
CHANGED
|
@@ -25,6 +25,7 @@ __export(src_exports, {
|
|
|
25
25
|
SsoError: () => SsoError,
|
|
26
26
|
clearAllSsoData: () => clearAllSsoData,
|
|
27
27
|
clearVerifiersForClients: () => clearVerifiersForClients,
|
|
28
|
+
createSecureStorage: () => createSecureStorage,
|
|
28
29
|
generateCodeChallenge: () => generateCodeChallenge,
|
|
29
30
|
generateCodeVerifier: () => generateCodeVerifier,
|
|
30
31
|
generateState: () => generateState,
|
|
@@ -33,6 +34,8 @@ __export(src_exports, {
|
|
|
33
34
|
getReturnUrl: () => getReturnUrl,
|
|
34
35
|
getTokenData: () => getTokenData,
|
|
35
36
|
getTokenStorage: () => getTokenStorage,
|
|
37
|
+
isTrustedReturnUrl: () => isTrustedReturnUrl,
|
|
38
|
+
mapOAuthErrorToSsoCode: () => mapOAuthErrorToSsoCode,
|
|
36
39
|
removeOAuthState: () => removeOAuthState,
|
|
37
40
|
removePkceVerifier: () => removePkceVerifier,
|
|
38
41
|
removeReturnUrl: () => removeReturnUrl,
|
|
@@ -41,11 +44,32 @@ __export(src_exports, {
|
|
|
41
44
|
savePkceVerifier: () => savePkceVerifier,
|
|
42
45
|
saveReturnUrl: () => saveReturnUrl,
|
|
43
46
|
saveTokenData: () => saveTokenData,
|
|
44
|
-
setTokenStorage: () => setTokenStorage
|
|
47
|
+
setTokenStorage: () => setTokenStorage,
|
|
48
|
+
timingSafeEqualString: () => timingSafeEqualString
|
|
45
49
|
});
|
|
46
50
|
module.exports = __toCommonJS(src_exports);
|
|
47
51
|
|
|
48
52
|
// src/core/errors.ts
|
|
53
|
+
function mapOAuthErrorToSsoCode(oauthError, context = "token_exchange") {
|
|
54
|
+
switch (oauthError) {
|
|
55
|
+
case "invalid_grant":
|
|
56
|
+
return context === "refresh" ? "session_expired" : "authorization_code_expired";
|
|
57
|
+
case "invalid_client":
|
|
58
|
+
return "client_disabled";
|
|
59
|
+
case "access_denied":
|
|
60
|
+
return "user_denied_authorization";
|
|
61
|
+
case "server_error":
|
|
62
|
+
return "sso_server_error";
|
|
63
|
+
case "rate_limited":
|
|
64
|
+
return "rate_limited";
|
|
65
|
+
case "invalid_scope":
|
|
66
|
+
case "invalid_request":
|
|
67
|
+
case "unauthorized_client":
|
|
68
|
+
case "unsupported_grant_type":
|
|
69
|
+
default:
|
|
70
|
+
return "token_request_failed";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
49
73
|
var SsoError = class extends Error {
|
|
50
74
|
constructor(code, description, cause) {
|
|
51
75
|
super(`[SSO SDK] ${code}: ${description}`);
|
|
@@ -128,6 +152,57 @@ function createMemoryStorageAdapter() {
|
|
|
128
152
|
};
|
|
129
153
|
}
|
|
130
154
|
var memoryStorageAdapter = createMemoryStorageAdapter();
|
|
155
|
+
var localStorageAdapter = {
|
|
156
|
+
get(key) {
|
|
157
|
+
if (typeof localStorage === "undefined") return null;
|
|
158
|
+
return localStorage.getItem(STORAGE_PREFIX + key);
|
|
159
|
+
},
|
|
160
|
+
set(key, value) {
|
|
161
|
+
if (typeof localStorage === "undefined") return;
|
|
162
|
+
try {
|
|
163
|
+
localStorage.setItem(STORAGE_PREFIX + key, value);
|
|
164
|
+
} catch (err) {
|
|
165
|
+
console.warn(
|
|
166
|
+
`[SSO SDK] localStorage \u5199\u5165\u5931\u8D25\uFF08${err instanceof Error ? err.name : String(err)}\uFF09\uFF0C\u6570\u636E\u672A\u6301\u4E45\u5316`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
remove(key) {
|
|
171
|
+
if (typeof localStorage === "undefined") return;
|
|
172
|
+
localStorage.removeItem(STORAGE_PREFIX + key);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
function createSessionStorageAdapter() {
|
|
176
|
+
const fallback = /* @__PURE__ */ new Map();
|
|
177
|
+
return {
|
|
178
|
+
get(key) {
|
|
179
|
+
if (typeof sessionStorage !== "undefined") {
|
|
180
|
+
return sessionStorage.getItem(STORAGE_PREFIX + key) ?? fallback.get(key) ?? null;
|
|
181
|
+
}
|
|
182
|
+
return fallback.get(key) ?? null;
|
|
183
|
+
},
|
|
184
|
+
set(key, value) {
|
|
185
|
+
if (typeof sessionStorage === "undefined") {
|
|
186
|
+
fallback.set(key, value);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
sessionStorage.setItem(STORAGE_PREFIX + key, value);
|
|
191
|
+
} catch {
|
|
192
|
+
fallback.set(key, value);
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
remove(key) {
|
|
196
|
+
fallback.delete(key);
|
|
197
|
+
if (typeof sessionStorage === "undefined") return;
|
|
198
|
+
sessionStorage.removeItem(STORAGE_PREFIX + key);
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
var _transient = createSessionStorageAdapter();
|
|
203
|
+
function createSecureStorage(options = {}) {
|
|
204
|
+
return options.persist ? localStorageAdapter : memoryStorageAdapter;
|
|
205
|
+
}
|
|
131
206
|
var _storage = memoryStorageAdapter;
|
|
132
207
|
function setTokenStorage(storage) {
|
|
133
208
|
_storage = storage;
|
|
@@ -151,31 +226,31 @@ function removeTokenData(clientId) {
|
|
|
151
226
|
_storage.remove(buildKey(TOKEN_KEY, clientId));
|
|
152
227
|
}
|
|
153
228
|
function savePkceVerifier(clientId, verifier) {
|
|
154
|
-
|
|
229
|
+
_transient.set(VERIFIER_KEY_PREFIX + clientId, verifier);
|
|
155
230
|
}
|
|
156
231
|
function getPkceVerifier(clientId) {
|
|
157
|
-
return
|
|
232
|
+
return _transient.get(VERIFIER_KEY_PREFIX + clientId);
|
|
158
233
|
}
|
|
159
234
|
function removePkceVerifier(clientId) {
|
|
160
|
-
|
|
235
|
+
_transient.remove(VERIFIER_KEY_PREFIX + clientId);
|
|
161
236
|
}
|
|
162
237
|
function saveOAuthState(state, clientId) {
|
|
163
|
-
|
|
238
|
+
_transient.set(buildKey(STATE_KEY, clientId), state);
|
|
164
239
|
}
|
|
165
240
|
function getOAuthState(clientId) {
|
|
166
|
-
return
|
|
241
|
+
return _transient.get(buildKey(STATE_KEY, clientId));
|
|
167
242
|
}
|
|
168
243
|
function removeOAuthState(clientId) {
|
|
169
|
-
|
|
244
|
+
_transient.remove(buildKey(STATE_KEY, clientId));
|
|
170
245
|
}
|
|
171
246
|
function saveReturnUrl(url, clientId) {
|
|
172
|
-
|
|
247
|
+
_transient.set(buildKey(RETURN_URL_KEY, clientId), url);
|
|
173
248
|
}
|
|
174
249
|
function getReturnUrl(clientId) {
|
|
175
|
-
return
|
|
250
|
+
return _transient.get(buildKey(RETURN_URL_KEY, clientId));
|
|
176
251
|
}
|
|
177
252
|
function removeReturnUrl(clientId) {
|
|
178
|
-
|
|
253
|
+
_transient.remove(buildKey(RETURN_URL_KEY, clientId));
|
|
179
254
|
}
|
|
180
255
|
function clearAllSsoData(clientId) {
|
|
181
256
|
if (clientId) {
|
|
@@ -183,28 +258,63 @@ function clearAllSsoData(clientId) {
|
|
|
183
258
|
removeOAuthState(clientId);
|
|
184
259
|
removeReturnUrl(clientId);
|
|
185
260
|
removePkceVerifier(clientId);
|
|
261
|
+
removePkceVerifier(`${clientId}_popup_nonce`);
|
|
186
262
|
return;
|
|
187
263
|
}
|
|
188
264
|
removeTokenData();
|
|
189
265
|
removeOAuthState();
|
|
190
266
|
removeReturnUrl();
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
267
|
+
const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
|
|
268
|
+
const stores = [
|
|
269
|
+
typeof sessionStorage !== "undefined" ? sessionStorage : null,
|
|
270
|
+
typeof localStorage !== "undefined" ? localStorage : null
|
|
271
|
+
];
|
|
272
|
+
for (const store of stores) {
|
|
273
|
+
if (!store) continue;
|
|
274
|
+
const keys = [];
|
|
275
|
+
for (let i = 0; i < store.length; i++) {
|
|
276
|
+
const key = store.key(i);
|
|
277
|
+
if (key?.startsWith(prefix)) keys.push(key);
|
|
278
|
+
}
|
|
279
|
+
for (const key of keys) {
|
|
280
|
+
_transient.remove(key.slice(STORAGE_PREFIX.length));
|
|
281
|
+
store.removeItem(key);
|
|
198
282
|
}
|
|
199
283
|
}
|
|
200
284
|
}
|
|
201
285
|
function clearVerifiersForClients(clientIds) {
|
|
202
286
|
for (const clientId of clientIds) {
|
|
203
|
-
|
|
287
|
+
_transient.remove(VERIFIER_KEY_PREFIX + clientId);
|
|
204
288
|
}
|
|
205
289
|
}
|
|
206
290
|
|
|
207
|
-
// src/core/
|
|
291
|
+
// src/core/security.ts
|
|
292
|
+
function isTrustedReturnUrl(url, currentOrigin) {
|
|
293
|
+
if (!url) return false;
|
|
294
|
+
if (url.startsWith("/") && !url.startsWith("//")) return true;
|
|
295
|
+
try {
|
|
296
|
+
return new URL(url).origin === currentOrigin;
|
|
297
|
+
} catch {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function timingSafeEqualString(a, b) {
|
|
302
|
+
const ba = new TextEncoder().encode(a);
|
|
303
|
+
const bb = new TextEncoder().encode(b);
|
|
304
|
+
if (ba.length === 0 || bb.length === 0) return ba.length === bb.length;
|
|
305
|
+
const len = Math.max(ba.length, bb.length);
|
|
306
|
+
let diff = ba.length ^ bb.length;
|
|
307
|
+
for (let i = 0; i < len; i++) {
|
|
308
|
+
diff |= ba[i % ba.length] ^ bb[i % bb.length];
|
|
309
|
+
}
|
|
310
|
+
return diff === 0;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// src/core/id-token.ts
|
|
314
|
+
var _crypto = typeof crypto !== "undefined" && crypto.subtle ? crypto : null;
|
|
315
|
+
function getCrypto() {
|
|
316
|
+
return _crypto ?? null;
|
|
317
|
+
}
|
|
208
318
|
function base64UrlDecode(input) {
|
|
209
319
|
const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
210
320
|
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
|
|
@@ -239,14 +349,32 @@ function normalizeIssuer(url) {
|
|
|
239
349
|
return url.replace(/\/+$/, "");
|
|
240
350
|
}
|
|
241
351
|
var cachedJwks = null;
|
|
352
|
+
var cachedDiscovery = null;
|
|
242
353
|
var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
243
|
-
async function
|
|
354
|
+
async function fetchDiscoveryDoc(baseUrl) {
|
|
244
355
|
const now = Date.now();
|
|
245
|
-
if (
|
|
356
|
+
if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
|
|
357
|
+
return cachedDiscovery.doc;
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`);
|
|
361
|
+
const doc = res.ok ? await res.json() : null;
|
|
362
|
+
cachedDiscovery = { baseUrl, doc, fetchedAt: now };
|
|
363
|
+
return doc;
|
|
364
|
+
} catch {
|
|
365
|
+
cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
async function fetchJwks(baseUrl, options = {}) {
|
|
370
|
+
const now = Date.now();
|
|
371
|
+
if (!options.forceRefresh && cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
|
|
246
372
|
return cachedJwks.jwks;
|
|
247
373
|
}
|
|
374
|
+
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
375
|
+
const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
|
|
248
376
|
try {
|
|
249
|
-
const res = await fetch(
|
|
377
|
+
const res = await fetch(jwksUri, options.forceRefresh ? { cache: "no-cache" } : void 0);
|
|
250
378
|
if (!res.ok) return null;
|
|
251
379
|
const jwks = await res.json();
|
|
252
380
|
cachedJwks = { baseUrl, jwks, fetchedAt: now };
|
|
@@ -255,24 +383,13 @@ async function fetchJwks(baseUrl) {
|
|
|
255
383
|
return null;
|
|
256
384
|
}
|
|
257
385
|
}
|
|
258
|
-
async function
|
|
259
|
-
const hash = await crypto.subtle.digest(
|
|
260
|
-
"SHA-256",
|
|
261
|
-
new TextEncoder().encode(accessToken)
|
|
262
|
-
);
|
|
263
|
-
const bytes = new Uint8Array(hash);
|
|
264
|
-
const half = bytes.slice(0, bytes.length / 2);
|
|
265
|
-
let binary = "";
|
|
266
|
-
for (const b of half) {
|
|
267
|
-
binary += String.fromCharCode(b);
|
|
268
|
-
}
|
|
269
|
-
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
270
|
-
}
|
|
271
|
-
async function verifyRs256(token, jwk) {
|
|
386
|
+
async function verifyRs256Signature(token, jwk) {
|
|
272
387
|
try {
|
|
273
388
|
const [headerB64, payloadB64, signature] = token.split(".");
|
|
274
389
|
if (!signature || !jwk.n || !jwk.e) return false;
|
|
275
|
-
const
|
|
390
|
+
const c = getCrypto();
|
|
391
|
+
if (!c) return false;
|
|
392
|
+
const cryptoKey = await c.subtle.importKey(
|
|
276
393
|
"jwk",
|
|
277
394
|
{ kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: false },
|
|
278
395
|
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
@@ -281,7 +398,7 @@ async function verifyRs256(token, jwk) {
|
|
|
281
398
|
);
|
|
282
399
|
const signatureBytes = base64UrlDecode(signature);
|
|
283
400
|
const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
|
|
284
|
-
return await
|
|
401
|
+
return await c.subtle.verify(
|
|
285
402
|
"RSASSA-PKCS1-v1_5",
|
|
286
403
|
cryptoKey,
|
|
287
404
|
signatureBytes,
|
|
@@ -291,7 +408,20 @@ async function verifyRs256(token, jwk) {
|
|
|
291
408
|
return false;
|
|
292
409
|
}
|
|
293
410
|
}
|
|
294
|
-
async function
|
|
411
|
+
async function computeAtHash(accessToken) {
|
|
412
|
+
const c = getCrypto();
|
|
413
|
+
if (!c) return "";
|
|
414
|
+
const hash = await c.subtle.digest("SHA-256", new TextEncoder().encode(accessToken));
|
|
415
|
+
const bytes = new Uint8Array(hash);
|
|
416
|
+
const half = bytes.slice(0, bytes.length / 2);
|
|
417
|
+
let binary = "";
|
|
418
|
+
for (const b of half) {
|
|
419
|
+
binary += String.fromCharCode(b);
|
|
420
|
+
}
|
|
421
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
422
|
+
}
|
|
423
|
+
async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
|
|
424
|
+
const { rejectHs256WhenRs256Available = true } = options;
|
|
295
425
|
const header = decodeJwtHeader(idToken);
|
|
296
426
|
if (!header) {
|
|
297
427
|
throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
|
|
@@ -300,54 +430,91 @@ async function validateIdToken(idToken, expectedIssuer, expectedClientId, access
|
|
|
300
430
|
if (typeof alg !== "string" || alg !== "RS256" && alg !== "HS256") {
|
|
301
431
|
throw new SsoError("id_token_unsupported_alg", `\u4E0D\u652F\u6301\u7684 ID Token \u7B7E\u540D\u7B97\u6CD5: ${alg}`);
|
|
302
432
|
}
|
|
433
|
+
const baseUrl = normalizeIssuer(expectedIssuer);
|
|
434
|
+
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
435
|
+
const normalizedIssuer = normalizeIssuer(discovery?.issuer || baseUrl);
|
|
303
436
|
if (alg === "RS256") {
|
|
304
|
-
const jwks = await fetchJwks(
|
|
437
|
+
const jwks = await fetchJwks(baseUrl);
|
|
305
438
|
if (!jwks) {
|
|
306
439
|
throw new SsoError("id_token_invalid_signature", "\u65E0\u6CD5\u83B7\u53D6 JWKS \u9A8C\u8BC1 ID Token \u7B7E\u540D");
|
|
307
440
|
}
|
|
308
441
|
const kid = typeof header.kid === "string" ? header.kid : void 0;
|
|
309
|
-
const
|
|
442
|
+
const matchCandidates = (set) => set.keys.filter(
|
|
310
443
|
(k) => k.kty === "RSA" && k.alg === "RS256" && k.use === "sig" && (kid ? k.kid === kid : true)
|
|
311
444
|
);
|
|
312
|
-
|
|
445
|
+
const verifyAny = async (keys) => {
|
|
446
|
+
for (const key of keys) {
|
|
447
|
+
if (await verifyRs256Signature(idToken, key)) return true;
|
|
448
|
+
}
|
|
449
|
+
return false;
|
|
450
|
+
};
|
|
451
|
+
let candidates = matchCandidates(jwks);
|
|
452
|
+
let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
|
|
453
|
+
if (candidates.length === 0 || !validSig) {
|
|
454
|
+
const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });
|
|
455
|
+
if (freshJwks) {
|
|
456
|
+
candidates = matchCandidates(freshJwks);
|
|
457
|
+
validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (candidates.length === 0) {
|
|
313
461
|
throw new SsoError("id_token_invalid_signature", "JWKS \u4E2D\u672A\u627E\u5230\u5339\u914D\u7684 RS256 \u516C\u94A5");
|
|
314
462
|
}
|
|
315
|
-
const validSig = await verifyRs256(idToken, key);
|
|
316
463
|
if (!validSig) {
|
|
317
464
|
throw new SsoError("id_token_invalid_signature", "ID Token \u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25");
|
|
318
465
|
}
|
|
319
466
|
}
|
|
320
467
|
if (alg === "HS256") {
|
|
321
|
-
|
|
322
|
-
|
|
468
|
+
if (rejectHs256WhenRs256Available) {
|
|
469
|
+
const jwks = await fetchJwks(baseUrl);
|
|
470
|
+
const hasRs256 = jwks?.keys?.some((k) => k.alg === "RS256" && k.use === "sig");
|
|
471
|
+
if (hasRs256) {
|
|
472
|
+
throw new SsoError("id_token_unsupported_alg", "SSO \u4E2D\u5FC3\u5DF2\u914D\u7F6E RS256\uFF0C\u62D2\u7EDD HS256 ID Token");
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
throw new SsoError(
|
|
476
|
+
"id_token_hs256_unsupported",
|
|
477
|
+
"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"
|
|
323
478
|
);
|
|
324
479
|
}
|
|
325
480
|
const payload = decodeJwtPayload(idToken);
|
|
326
481
|
if (!payload) {
|
|
327
482
|
throw new SsoError("id_token_invalid", "ID Token payload \u89E3\u6790\u5931\u8D25");
|
|
328
483
|
}
|
|
329
|
-
const normalizedExpectedIssuer = normalizeIssuer(expectedIssuer);
|
|
330
484
|
const tokenIssuer = typeof payload.iss === "string" ? normalizeIssuer(payload.iss) : "";
|
|
331
|
-
if (tokenIssuer !==
|
|
485
|
+
if (tokenIssuer !== normalizedIssuer) {
|
|
332
486
|
throw new SsoError("id_token_issuer_mismatch", "ID Token issuer \u4E0D\u5339\u914D");
|
|
333
487
|
}
|
|
334
|
-
|
|
488
|
+
const aud = payload.aud;
|
|
489
|
+
const audList = Array.isArray(aud) ? aud : typeof aud === "string" ? [aud] : [];
|
|
490
|
+
if (!audList.includes(expectedClientId)) {
|
|
335
491
|
throw new SsoError("id_token_audience_mismatch", "ID Token audience \u4E0D\u5339\u914D");
|
|
336
492
|
}
|
|
337
|
-
if (
|
|
493
|
+
if (audList.length > 1 && payload.azp !== expectedClientId) {
|
|
494
|
+
throw new SsoError("id_token_audience_mismatch", "ID Token \u591A audience \u65F6 azp \u5FC5\u987B\u4E3A\u5F53\u524D client");
|
|
495
|
+
}
|
|
496
|
+
if (typeof payload.exp !== "number") {
|
|
497
|
+
throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 exp \u58F0\u660E");
|
|
498
|
+
}
|
|
499
|
+
if (Date.now() >= payload.exp * 1e3) {
|
|
338
500
|
throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
|
|
339
501
|
}
|
|
502
|
+
if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
|
|
503
|
+
throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
|
|
504
|
+
}
|
|
340
505
|
if (typeof payload.sub !== "string" || !payload.sub) {
|
|
341
506
|
throw new SsoError("id_token_missing_sub", "ID Token \u7F3A\u5C11 sub");
|
|
342
507
|
}
|
|
343
508
|
if (typeof payload.at_hash === "string" && payload.at_hash) {
|
|
344
509
|
const actual = await computeAtHash(accessToken);
|
|
345
|
-
if (actual
|
|
510
|
+
if (!timingSafeEqualString(actual, payload.at_hash)) {
|
|
346
511
|
throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
|
|
347
512
|
}
|
|
348
513
|
}
|
|
349
514
|
return { sub: payload.sub };
|
|
350
515
|
}
|
|
516
|
+
|
|
517
|
+
// src/core/SsoClient.ts
|
|
351
518
|
var _SsoClient = class _SsoClient {
|
|
352
519
|
constructor(config) {
|
|
353
520
|
this._discovery = null;
|
|
@@ -416,6 +583,17 @@ var _SsoClient = class _SsoClient {
|
|
|
416
583
|
if (d) return d.userinfo_endpoint;
|
|
417
584
|
return `${this.config.ssoBaseUrl}/api/oauth/userinfo`;
|
|
418
585
|
}
|
|
586
|
+
/**
|
|
587
|
+
* 开放重定向防护:仅保存相对路径或与当前页面同源的 returnUrl,
|
|
588
|
+
* 其余(如 https://evil.com)忽略并告警,防止回调后跳转到钓鱼站点。
|
|
589
|
+
*/
|
|
590
|
+
_saveReturnUrlIfTrusted(returnUrl) {
|
|
591
|
+
if (isTrustedReturnUrl(returnUrl, window.location.origin)) {
|
|
592
|
+
saveReturnUrl(returnUrl);
|
|
593
|
+
} else {
|
|
594
|
+
console.warn(`[SSO SDK] returnUrl \u672A\u901A\u8FC7\u540C\u6E90\u6821\u9A8C\uFF0C\u5DF2\u5FFD\u7565: ${returnUrl}`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
419
597
|
// ============================================
|
|
420
598
|
// 公共 API
|
|
421
599
|
// ============================================
|
|
@@ -425,7 +603,8 @@ var _SsoClient = class _SsoClient {
|
|
|
425
603
|
* 生成 PKCE code_verifier/code_challenge 和 state 参数,
|
|
426
604
|
* 构建 authorize URL,通过 302 跳转到 SSO 登录页。
|
|
427
605
|
*
|
|
428
|
-
* @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage
|
|
606
|
+
* @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
|
|
607
|
+
* 仅允许相对路径或同源绝对 URL,否则忽略并告警)
|
|
429
608
|
*/
|
|
430
609
|
async login(returnUrl) {
|
|
431
610
|
const verifier = generateCodeVerifier();
|
|
@@ -434,7 +613,7 @@ var _SsoClient = class _SsoClient {
|
|
|
434
613
|
savePkceVerifier(this.config.clientId, verifier);
|
|
435
614
|
saveOAuthState(state, this.config.clientId);
|
|
436
615
|
if (returnUrl) {
|
|
437
|
-
|
|
616
|
+
this._saveReturnUrlIfTrusted(returnUrl);
|
|
438
617
|
}
|
|
439
618
|
const authorizeEndpoint = await this._getAuthorizeEndpoint();
|
|
440
619
|
const params = new URLSearchParams();
|
|
@@ -458,7 +637,7 @@ var _SsoClient = class _SsoClient {
|
|
|
458
637
|
const state = generateState();
|
|
459
638
|
savePkceVerifier(this.config.clientId, verifier);
|
|
460
639
|
saveOAuthState(state, this.config.clientId);
|
|
461
|
-
if (returnUrl)
|
|
640
|
+
if (returnUrl) this._saveReturnUrlIfTrusted(returnUrl);
|
|
462
641
|
const authorizeEndpoint = await this._getAuthorizeEndpoint();
|
|
463
642
|
const params = new URLSearchParams();
|
|
464
643
|
params.set("response_type", "code");
|
|
@@ -470,11 +649,140 @@ var _SsoClient = class _SsoClient {
|
|
|
470
649
|
params.set("code_challenge_method", "S256");
|
|
471
650
|
return `${authorizeEndpoint}?${params.toString()}`;
|
|
472
651
|
}
|
|
652
|
+
/**
|
|
653
|
+
* 弹窗模式 SSO 登录
|
|
654
|
+
*
|
|
655
|
+
* 打开一个小窗口进行登录,认证完成后窗口自动关闭,
|
|
656
|
+
* 主页面不丢失状态(适用于 SPA 中需要保持表单/浏览上下文的场景)。
|
|
657
|
+
*
|
|
658
|
+
* 流程:
|
|
659
|
+
* 1. window.open() 打开授权 URL 到弹窗
|
|
660
|
+
* 2. 用户在弹窗中完成登录
|
|
661
|
+
* 3. 弹窗加载 CallbackPage 时检测到 window.opener,通过 postMessage 回传回调 URL
|
|
662
|
+
* 4. 主页面收到消息后调用 handleCallback() 交换 token
|
|
663
|
+
* 5. 弹窗自动关闭
|
|
664
|
+
*
|
|
665
|
+
* @param options - 弹窗配置
|
|
666
|
+
* @param options.returnUrl - 登录成功后的返回地址
|
|
667
|
+
* @param options.width - 弹窗宽度(默认 480)
|
|
668
|
+
* @param options.height - 弹窗高度(默认 640)
|
|
669
|
+
* @returns TokenData
|
|
670
|
+
*
|
|
671
|
+
* @example
|
|
672
|
+
* ```typescript
|
|
673
|
+
* const client = new SsoClient({ clientId: "xxx", redirectUri: "https://myapp.com/callback", ssoBaseUrl: "https://nihplod.cn" });
|
|
674
|
+
* try {
|
|
675
|
+
* const tokenData = await client.loginPopup({ returnUrl: "/dashboard" });
|
|
676
|
+
* console.log("登录成功", tokenData);
|
|
677
|
+
* } catch (err) {
|
|
678
|
+
* if (err instanceof SsoError && err.code === "popup_blocked") {
|
|
679
|
+
* // 弹窗被拦截,回退到同页重定向
|
|
680
|
+
* await client.login("/dashboard");
|
|
681
|
+
* }
|
|
682
|
+
* }
|
|
683
|
+
* ```
|
|
684
|
+
*/
|
|
685
|
+
async loginPopup(options = {}) {
|
|
686
|
+
const verifier = generateCodeVerifier();
|
|
687
|
+
const challenge = await generateCodeChallenge(verifier);
|
|
688
|
+
const state = generateState();
|
|
689
|
+
const popupNonce = generateState();
|
|
690
|
+
savePkceVerifier(this.config.clientId, verifier);
|
|
691
|
+
saveOAuthState(state, this.config.clientId);
|
|
692
|
+
savePkceVerifier(`${this.config.clientId}_popup_nonce`, popupNonce);
|
|
693
|
+
if (options.returnUrl) {
|
|
694
|
+
this._saveReturnUrlIfTrusted(options.returnUrl);
|
|
695
|
+
}
|
|
696
|
+
const authorizeEndpoint = await this._getAuthorizeEndpoint();
|
|
697
|
+
const params = new URLSearchParams();
|
|
698
|
+
params.set("response_type", "code");
|
|
699
|
+
params.set("client_id", this.config.clientId);
|
|
700
|
+
params.set("redirect_uri", this.config.redirectUri);
|
|
701
|
+
params.set("scope", this.config.scopes || "openid profile");
|
|
702
|
+
params.set("state", state);
|
|
703
|
+
params.set("code_challenge", challenge);
|
|
704
|
+
params.set("code_challenge_method", "S256");
|
|
705
|
+
params.set("popup_nonce", popupNonce);
|
|
706
|
+
const width = options.width || 480;
|
|
707
|
+
const height = options.height || 640;
|
|
708
|
+
const left = Math.max(0, (window.screen.width - width) / 2);
|
|
709
|
+
const top = Math.max(0, (window.screen.height - height) / 2);
|
|
710
|
+
const popupFeatures = [
|
|
711
|
+
`width=${width}`,
|
|
712
|
+
`height=${height}`,
|
|
713
|
+
`left=${Math.round(left)}`,
|
|
714
|
+
`top=${Math.round(top)}`,
|
|
715
|
+
"resizable=yes",
|
|
716
|
+
"scrollbars=yes",
|
|
717
|
+
"status=yes"
|
|
718
|
+
].join(",");
|
|
719
|
+
const popup = window.open(
|
|
720
|
+
`${authorizeEndpoint}?${params.toString()}`,
|
|
721
|
+
"nihplod_sso_popup",
|
|
722
|
+
popupFeatures
|
|
723
|
+
);
|
|
724
|
+
if (!popup) {
|
|
725
|
+
removePkceVerifier(this.config.clientId);
|
|
726
|
+
removeOAuthState(this.config.clientId);
|
|
727
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
728
|
+
throw new SsoError(
|
|
729
|
+
"popup_blocked",
|
|
730
|
+
"\u5F39\u7A97\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5"
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
const redirectUriOrigin = new URL(this.config.redirectUri).origin;
|
|
734
|
+
try {
|
|
735
|
+
popup.focus();
|
|
736
|
+
return await new Promise((resolve, reject) => {
|
|
737
|
+
let completed = false;
|
|
738
|
+
const handleMessage = (event) => {
|
|
739
|
+
if (completed) return;
|
|
740
|
+
if (event.origin !== redirectUriOrigin) return;
|
|
741
|
+
if (!event.data || event.data.type !== "nihplod_sso_popup_callback") return;
|
|
742
|
+
if (!event.data.callbackUrl) return;
|
|
743
|
+
const savedNonce = getPkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
744
|
+
if (!savedNonce || !timingSafeEqualString(event.data.nonce ?? "", savedNonce)) return;
|
|
745
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
746
|
+
completed = true;
|
|
747
|
+
cleanup();
|
|
748
|
+
if (popup && !popup.closed) {
|
|
749
|
+
popup.close();
|
|
750
|
+
}
|
|
751
|
+
this.handleCallback(event.data.callbackUrl).then(resolve).catch(reject);
|
|
752
|
+
};
|
|
753
|
+
const pollTimer = setInterval(() => {
|
|
754
|
+
if (popup.closed) {
|
|
755
|
+
if (!completed) {
|
|
756
|
+
completed = true;
|
|
757
|
+
cleanup();
|
|
758
|
+
reject(
|
|
759
|
+
new SsoError(
|
|
760
|
+
"popup_closed",
|
|
761
|
+
"\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED"
|
|
762
|
+
)
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}, 500);
|
|
767
|
+
const cleanup = () => {
|
|
768
|
+
clearInterval(pollTimer);
|
|
769
|
+
window.removeEventListener("message", handleMessage);
|
|
770
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
771
|
+
};
|
|
772
|
+
window.addEventListener("message", handleMessage);
|
|
773
|
+
});
|
|
774
|
+
} catch (err) {
|
|
775
|
+
removePkceVerifier(this.config.clientId);
|
|
776
|
+
removeOAuthState(this.config.clientId);
|
|
777
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
778
|
+
throw err;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
473
781
|
/**
|
|
474
782
|
* 处理 OAuth 回调
|
|
475
783
|
*
|
|
476
784
|
* 解析回调 URL,校验 state 参数,用授权码交换 token。
|
|
477
|
-
* 成功后 token 自动保存到
|
|
785
|
+
* 成功后 token 自动保存到 token 存储(默认内存,可通过 setTokenStorage 定制)。
|
|
478
786
|
*
|
|
479
787
|
* @param callbackUrl - 完整的回调 URL(window.location.href)
|
|
480
788
|
* @returns TokenData 或 null
|
|
@@ -493,14 +801,14 @@ var _SsoClient = class _SsoClient {
|
|
|
493
801
|
throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
|
|
494
802
|
}
|
|
495
803
|
const savedState = getOAuthState(this.config.clientId);
|
|
496
|
-
if (!savedState || savedState
|
|
804
|
+
if (!savedState || !timingSafeEqualString(savedState, returnedState ?? "")) {
|
|
497
805
|
removeOAuthState(this.config.clientId);
|
|
806
|
+
removePkceVerifier(this.config.clientId);
|
|
498
807
|
throw new SsoError(
|
|
499
808
|
"state_mismatch",
|
|
500
809
|
"State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
|
|
501
810
|
);
|
|
502
811
|
}
|
|
503
|
-
removeOAuthState(this.config.clientId);
|
|
504
812
|
const verifier = getPkceVerifier(this.config.clientId);
|
|
505
813
|
if (!verifier) {
|
|
506
814
|
throw new SsoError(
|
|
@@ -508,7 +816,6 @@ var _SsoClient = class _SsoClient {
|
|
|
508
816
|
"code_verifier \u4E0D\u5B58\u5728\uFF08\u53EF\u80FD\u5DF2\u8FC7\u671F\u6216\u6765\u81EA\u5176\u4ED6\u6807\u7B7E\u9875\uFF09"
|
|
509
817
|
);
|
|
510
818
|
}
|
|
511
|
-
removePkceVerifier(this.config.clientId);
|
|
512
819
|
const tokenEndpoint = await this._getTokenEndpoint();
|
|
513
820
|
const body = new URLSearchParams();
|
|
514
821
|
body.set("grant_type", "authorization_code");
|
|
@@ -535,19 +842,22 @@ var _SsoClient = class _SsoClient {
|
|
|
535
842
|
errData = await res.json();
|
|
536
843
|
} catch {
|
|
537
844
|
}
|
|
845
|
+
const serverError = errData.error || "";
|
|
538
846
|
throw new SsoError(
|
|
539
|
-
|
|
847
|
+
mapOAuthErrorToSsoCode(serverError),
|
|
540
848
|
errData.error_description || `Token \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}`
|
|
541
849
|
);
|
|
542
850
|
}
|
|
543
851
|
const data = await res.json();
|
|
852
|
+
removeOAuthState(this.config.clientId);
|
|
853
|
+
removePkceVerifier(this.config.clientId);
|
|
544
854
|
if (data.id_token) {
|
|
545
855
|
try {
|
|
546
856
|
await validateIdToken(
|
|
547
857
|
data.id_token,
|
|
858
|
+
data.access_token,
|
|
548
859
|
this.config.ssoBaseUrl,
|
|
549
|
-
this.config.clientId
|
|
550
|
-
data.access_token
|
|
860
|
+
this.config.clientId
|
|
551
861
|
);
|
|
552
862
|
} catch (err) {
|
|
553
863
|
removeTokenData(this.config.clientId);
|
|
@@ -596,15 +906,24 @@ var _SsoClient = class _SsoClient {
|
|
|
596
906
|
if (this.config.clientSecret) {
|
|
597
907
|
body.set("client_secret", this.config.clientSecret);
|
|
598
908
|
}
|
|
599
|
-
let res;
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
909
|
+
let res = null;
|
|
910
|
+
let lastErr;
|
|
911
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
912
|
+
try {
|
|
913
|
+
res = await fetch(tokenEndpoint, {
|
|
914
|
+
method: "POST",
|
|
915
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
916
|
+
body: body.toString()
|
|
917
|
+
});
|
|
918
|
+
lastErr = null;
|
|
919
|
+
break;
|
|
920
|
+
} catch (err) {
|
|
921
|
+
lastErr = err;
|
|
922
|
+
if (attempt < 1) await new Promise((r) => setTimeout(r, 1e3));
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
if (lastErr || !res) {
|
|
926
|
+
throw new SsoError("network_error", "\u5237\u65B0 Token \u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", lastErr);
|
|
608
927
|
}
|
|
609
928
|
if (!res.ok) {
|
|
610
929
|
let errData = {};
|
|
@@ -617,17 +936,31 @@ var _SsoClient = class _SsoClient {
|
|
|
617
936
|
removeTokenData(this.config.clientId);
|
|
618
937
|
}
|
|
619
938
|
throw new SsoError(
|
|
620
|
-
errorCode
|
|
939
|
+
mapOAuthErrorToSsoCode(errorCode, "refresh"),
|
|
621
940
|
errData.error_description || `\u5237\u65B0 Token \u5931\u8D25: HTTP ${res.status}`
|
|
622
941
|
);
|
|
623
942
|
}
|
|
624
943
|
const data = await res.json();
|
|
944
|
+
if (data.id_token) {
|
|
945
|
+
try {
|
|
946
|
+
await validateIdToken(
|
|
947
|
+
data.id_token,
|
|
948
|
+
data.access_token,
|
|
949
|
+
this.config.ssoBaseUrl,
|
|
950
|
+
this.config.clientId
|
|
951
|
+
);
|
|
952
|
+
} catch (err) {
|
|
953
|
+
removeTokenData(this.config.clientId);
|
|
954
|
+
throw err;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
625
957
|
const now = Date.now();
|
|
626
958
|
const tokenData = {
|
|
627
959
|
access_token: data.access_token,
|
|
628
960
|
token_type: data.token_type,
|
|
629
961
|
expires_in: data.expires_in,
|
|
630
|
-
|
|
962
|
+
// RFC 6749 §6:刷新响应可省略 refresh_token,此时沿用旧值
|
|
963
|
+
refresh_token: data.refresh_token || current.refresh_token,
|
|
631
964
|
id_token: data.id_token,
|
|
632
965
|
issued_at: now,
|
|
633
966
|
expires_at: now + data.expires_in * 1e3
|
|
@@ -728,13 +1061,14 @@ var _SsoClient = class _SsoClient {
|
|
|
728
1061
|
}
|
|
729
1062
|
if (redirectToSso) {
|
|
730
1063
|
const discovery = await this._getDiscovery();
|
|
731
|
-
const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/
|
|
1064
|
+
const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/api/oauth/end-session`;
|
|
732
1065
|
const logoutUrl = new URL(endSessionEndpoint);
|
|
733
1066
|
logoutUrl.searchParams.set("client_id", this.config.clientId);
|
|
734
|
-
|
|
1067
|
+
const postLogoutUri = this.config.postLogoutRedirectUri || this.config.redirectUri;
|
|
1068
|
+
if (postLogoutUri) {
|
|
735
1069
|
logoutUrl.searchParams.set(
|
|
736
1070
|
"post_logout_redirect_uri",
|
|
737
|
-
|
|
1071
|
+
postLogoutUri
|
|
738
1072
|
);
|
|
739
1073
|
}
|
|
740
1074
|
if (idTokenHint) {
|
|
@@ -767,6 +1101,7 @@ var SsoClient = _SsoClient;
|
|
|
767
1101
|
SsoError,
|
|
768
1102
|
clearAllSsoData,
|
|
769
1103
|
clearVerifiersForClients,
|
|
1104
|
+
createSecureStorage,
|
|
770
1105
|
generateCodeChallenge,
|
|
771
1106
|
generateCodeVerifier,
|
|
772
1107
|
generateState,
|
|
@@ -775,6 +1110,8 @@ var SsoClient = _SsoClient;
|
|
|
775
1110
|
getReturnUrl,
|
|
776
1111
|
getTokenData,
|
|
777
1112
|
getTokenStorage,
|
|
1113
|
+
isTrustedReturnUrl,
|
|
1114
|
+
mapOAuthErrorToSsoCode,
|
|
778
1115
|
removeOAuthState,
|
|
779
1116
|
removePkceVerifier,
|
|
780
1117
|
removeReturnUrl,
|
|
@@ -783,6 +1120,7 @@ var SsoClient = _SsoClient;
|
|
|
783
1120
|
savePkceVerifier,
|
|
784
1121
|
saveReturnUrl,
|
|
785
1122
|
saveTokenData,
|
|
786
|
-
setTokenStorage
|
|
1123
|
+
setTokenStorage,
|
|
1124
|
+
timingSafeEqualString
|
|
787
1125
|
});
|
|
788
1126
|
//# sourceMappingURL=index.js.map
|