@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/index.js
CHANGED
|
@@ -25,27 +25,54 @@ __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,
|
|
32
|
+
getLogoutState: () => getLogoutState,
|
|
31
33
|
getOAuthState: () => getOAuthState,
|
|
32
34
|
getPkceVerifier: () => getPkceVerifier,
|
|
33
35
|
getReturnUrl: () => getReturnUrl,
|
|
34
36
|
getTokenData: () => getTokenData,
|
|
35
37
|
getTokenStorage: () => getTokenStorage,
|
|
38
|
+
isTrustedReturnUrl: () => isTrustedReturnUrl,
|
|
39
|
+
mapOAuthErrorToSsoCode: () => mapOAuthErrorToSsoCode,
|
|
40
|
+
removeLogoutState: () => removeLogoutState,
|
|
36
41
|
removeOAuthState: () => removeOAuthState,
|
|
37
42
|
removePkceVerifier: () => removePkceVerifier,
|
|
38
43
|
removeReturnUrl: () => removeReturnUrl,
|
|
39
44
|
removeTokenData: () => removeTokenData,
|
|
45
|
+
saveLogoutState: () => saveLogoutState,
|
|
40
46
|
saveOAuthState: () => saveOAuthState,
|
|
41
47
|
savePkceVerifier: () => savePkceVerifier,
|
|
42
48
|
saveReturnUrl: () => saveReturnUrl,
|
|
43
49
|
saveTokenData: () => saveTokenData,
|
|
44
|
-
setTokenStorage: () => setTokenStorage
|
|
50
|
+
setTokenStorage: () => setTokenStorage,
|
|
51
|
+
timingSafeEqualString: () => timingSafeEqualString
|
|
45
52
|
});
|
|
46
53
|
module.exports = __toCommonJS(src_exports);
|
|
47
54
|
|
|
48
55
|
// src/core/errors.ts
|
|
56
|
+
function mapOAuthErrorToSsoCode(oauthError, context = "token_exchange") {
|
|
57
|
+
switch (oauthError) {
|
|
58
|
+
case "invalid_grant":
|
|
59
|
+
return context === "refresh" ? "session_expired" : "authorization_code_expired";
|
|
60
|
+
case "invalid_client":
|
|
61
|
+
return "client_disabled";
|
|
62
|
+
case "access_denied":
|
|
63
|
+
return "user_denied_authorization";
|
|
64
|
+
case "server_error":
|
|
65
|
+
return "sso_server_error";
|
|
66
|
+
case "rate_limited":
|
|
67
|
+
return "rate_limited";
|
|
68
|
+
case "invalid_scope":
|
|
69
|
+
case "invalid_request":
|
|
70
|
+
case "unauthorized_client":
|
|
71
|
+
case "unsupported_grant_type":
|
|
72
|
+
default:
|
|
73
|
+
return "token_request_failed";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
49
76
|
var SsoError = class extends Error {
|
|
50
77
|
constructor(code, description, cause) {
|
|
51
78
|
super(`[SSO SDK] ${code}: ${description}`);
|
|
@@ -110,6 +137,7 @@ var TOKEN_KEY = "token";
|
|
|
110
137
|
var VERIFIER_KEY_PREFIX = "pkce_verifier_";
|
|
111
138
|
var STATE_KEY = "oauth_state";
|
|
112
139
|
var RETURN_URL_KEY = "return_url";
|
|
140
|
+
var LOGOUT_STATE_KEY = "logout_state";
|
|
113
141
|
function buildKey(base, clientId) {
|
|
114
142
|
return clientId ? `${base}:${clientId}` : base;
|
|
115
143
|
}
|
|
@@ -128,7 +156,58 @@ function createMemoryStorageAdapter() {
|
|
|
128
156
|
};
|
|
129
157
|
}
|
|
130
158
|
var memoryStorageAdapter = createMemoryStorageAdapter();
|
|
131
|
-
var
|
|
159
|
+
var localStorageAdapter = {
|
|
160
|
+
get(key) {
|
|
161
|
+
if (typeof localStorage === "undefined") return null;
|
|
162
|
+
return localStorage.getItem(STORAGE_PREFIX + key);
|
|
163
|
+
},
|
|
164
|
+
set(key, value) {
|
|
165
|
+
if (typeof localStorage === "undefined") return;
|
|
166
|
+
try {
|
|
167
|
+
localStorage.setItem(STORAGE_PREFIX + key, value);
|
|
168
|
+
} catch (err) {
|
|
169
|
+
console.warn(
|
|
170
|
+
`[SSO SDK] localStorage \u5199\u5165\u5931\u8D25\uFF08${err instanceof Error ? err.name : String(err)}\uFF09\uFF0C\u6570\u636E\u672A\u6301\u4E45\u5316`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
remove(key) {
|
|
175
|
+
if (typeof localStorage === "undefined") return;
|
|
176
|
+
localStorage.removeItem(STORAGE_PREFIX + key);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
function createSessionStorageAdapter() {
|
|
180
|
+
const fallback = /* @__PURE__ */ new Map();
|
|
181
|
+
return {
|
|
182
|
+
get(key) {
|
|
183
|
+
if (typeof sessionStorage !== "undefined") {
|
|
184
|
+
return sessionStorage.getItem(STORAGE_PREFIX + key) ?? fallback.get(key) ?? null;
|
|
185
|
+
}
|
|
186
|
+
return fallback.get(key) ?? null;
|
|
187
|
+
},
|
|
188
|
+
set(key, value) {
|
|
189
|
+
if (typeof sessionStorage === "undefined") {
|
|
190
|
+
fallback.set(key, value);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
sessionStorage.setItem(STORAGE_PREFIX + key, value);
|
|
195
|
+
} catch {
|
|
196
|
+
fallback.set(key, value);
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
remove(key) {
|
|
200
|
+
fallback.delete(key);
|
|
201
|
+
if (typeof sessionStorage === "undefined") return;
|
|
202
|
+
sessionStorage.removeItem(STORAGE_PREFIX + key);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
var _transient = createSessionStorageAdapter();
|
|
207
|
+
function createSecureStorage(options = {}) {
|
|
208
|
+
return options.persist ? localStorageAdapter : createSessionStorageAdapter();
|
|
209
|
+
}
|
|
210
|
+
var _storage = createSessionStorageAdapter();
|
|
132
211
|
function setTokenStorage(storage) {
|
|
133
212
|
_storage = storage;
|
|
134
213
|
}
|
|
@@ -151,60 +230,107 @@ function removeTokenData(clientId) {
|
|
|
151
230
|
_storage.remove(buildKey(TOKEN_KEY, clientId));
|
|
152
231
|
}
|
|
153
232
|
function savePkceVerifier(clientId, verifier) {
|
|
154
|
-
|
|
233
|
+
_transient.set(VERIFIER_KEY_PREFIX + clientId, verifier);
|
|
155
234
|
}
|
|
156
235
|
function getPkceVerifier(clientId) {
|
|
157
|
-
return
|
|
236
|
+
return _transient.get(VERIFIER_KEY_PREFIX + clientId);
|
|
158
237
|
}
|
|
159
238
|
function removePkceVerifier(clientId) {
|
|
160
|
-
|
|
239
|
+
_transient.remove(VERIFIER_KEY_PREFIX + clientId);
|
|
161
240
|
}
|
|
162
241
|
function saveOAuthState(state, clientId) {
|
|
163
|
-
|
|
242
|
+
_transient.set(buildKey(STATE_KEY, clientId), state);
|
|
164
243
|
}
|
|
165
244
|
function getOAuthState(clientId) {
|
|
166
|
-
return
|
|
245
|
+
return _transient.get(buildKey(STATE_KEY, clientId));
|
|
167
246
|
}
|
|
168
247
|
function removeOAuthState(clientId) {
|
|
169
|
-
|
|
248
|
+
_transient.remove(buildKey(STATE_KEY, clientId));
|
|
249
|
+
}
|
|
250
|
+
function saveLogoutState(state, clientId) {
|
|
251
|
+
_transient.set(buildKey(LOGOUT_STATE_KEY, clientId), state);
|
|
252
|
+
}
|
|
253
|
+
function getLogoutState(clientId) {
|
|
254
|
+
return _transient.get(buildKey(LOGOUT_STATE_KEY, clientId));
|
|
255
|
+
}
|
|
256
|
+
function removeLogoutState(clientId) {
|
|
257
|
+
_transient.remove(buildKey(LOGOUT_STATE_KEY, clientId));
|
|
170
258
|
}
|
|
171
259
|
function saveReturnUrl(url, clientId) {
|
|
172
|
-
|
|
260
|
+
_transient.set(buildKey(RETURN_URL_KEY, clientId), url);
|
|
173
261
|
}
|
|
174
262
|
function getReturnUrl(clientId) {
|
|
175
|
-
return
|
|
263
|
+
return _transient.get(buildKey(RETURN_URL_KEY, clientId));
|
|
176
264
|
}
|
|
177
265
|
function removeReturnUrl(clientId) {
|
|
178
|
-
|
|
266
|
+
_transient.remove(buildKey(RETURN_URL_KEY, clientId));
|
|
179
267
|
}
|
|
180
268
|
function clearAllSsoData(clientId) {
|
|
181
269
|
if (clientId) {
|
|
182
270
|
removeTokenData(clientId);
|
|
183
271
|
removeOAuthState(clientId);
|
|
184
272
|
removeReturnUrl(clientId);
|
|
273
|
+
removeLogoutState(clientId);
|
|
185
274
|
removePkceVerifier(clientId);
|
|
275
|
+
removePkceVerifier(`${clientId}_popup_nonce`);
|
|
186
276
|
return;
|
|
187
277
|
}
|
|
188
278
|
removeTokenData();
|
|
189
279
|
removeOAuthState();
|
|
190
280
|
removeReturnUrl();
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
281
|
+
removeLogoutState();
|
|
282
|
+
const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
|
|
283
|
+
const stores = [
|
|
284
|
+
typeof sessionStorage !== "undefined" ? sessionStorage : null,
|
|
285
|
+
typeof localStorage !== "undefined" ? localStorage : null
|
|
286
|
+
];
|
|
287
|
+
for (const store of stores) {
|
|
288
|
+
if (!store) continue;
|
|
289
|
+
const keys = [];
|
|
290
|
+
for (let i = 0; i < store.length; i++) {
|
|
291
|
+
const key = store.key(i);
|
|
292
|
+
if (key?.startsWith(prefix)) keys.push(key);
|
|
293
|
+
}
|
|
294
|
+
for (const key of keys) {
|
|
295
|
+
_transient.remove(key.slice(STORAGE_PREFIX.length));
|
|
296
|
+
store.removeItem(key);
|
|
198
297
|
}
|
|
199
298
|
}
|
|
200
299
|
}
|
|
201
300
|
function clearVerifiersForClients(clientIds) {
|
|
202
301
|
for (const clientId of clientIds) {
|
|
203
|
-
|
|
302
|
+
_transient.remove(VERIFIER_KEY_PREFIX + clientId);
|
|
204
303
|
}
|
|
205
304
|
}
|
|
206
305
|
|
|
207
|
-
// src/core/
|
|
306
|
+
// src/core/security.ts
|
|
307
|
+
function isTrustedReturnUrl(url, currentOrigin) {
|
|
308
|
+
if (!url) return false;
|
|
309
|
+
if (url.includes("\\")) return false;
|
|
310
|
+
if (url.startsWith("/") && !url.startsWith("//")) return true;
|
|
311
|
+
try {
|
|
312
|
+
return new URL(url).origin === currentOrigin;
|
|
313
|
+
} catch {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function timingSafeEqualString(a, b) {
|
|
318
|
+
const ba = new TextEncoder().encode(a);
|
|
319
|
+
const bb = new TextEncoder().encode(b);
|
|
320
|
+
if (ba.length === 0 || bb.length === 0) return ba.length === bb.length;
|
|
321
|
+
const len = Math.max(ba.length, bb.length);
|
|
322
|
+
let diff = ba.length ^ bb.length;
|
|
323
|
+
for (let i = 0; i < len; i++) {
|
|
324
|
+
diff |= ba[i % ba.length] ^ bb[i % bb.length];
|
|
325
|
+
}
|
|
326
|
+
return diff === 0;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// src/core/id-token.ts
|
|
330
|
+
var _crypto = typeof crypto !== "undefined" && crypto.subtle ? crypto : null;
|
|
331
|
+
function getCrypto() {
|
|
332
|
+
return _crypto ?? null;
|
|
333
|
+
}
|
|
208
334
|
function base64UrlDecode(input) {
|
|
209
335
|
const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
210
336
|
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
|
|
@@ -239,14 +365,32 @@ function normalizeIssuer(url) {
|
|
|
239
365
|
return url.replace(/\/+$/, "");
|
|
240
366
|
}
|
|
241
367
|
var cachedJwks = null;
|
|
368
|
+
var cachedDiscovery = null;
|
|
242
369
|
var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
243
|
-
async function
|
|
370
|
+
async function fetchDiscoveryDoc(baseUrl) {
|
|
371
|
+
const now = Date.now();
|
|
372
|
+
if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
|
|
373
|
+
return cachedDiscovery.doc;
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`);
|
|
377
|
+
const doc = res.ok ? await res.json() : null;
|
|
378
|
+
cachedDiscovery = { baseUrl, doc, fetchedAt: now };
|
|
379
|
+
return doc;
|
|
380
|
+
} catch {
|
|
381
|
+
cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
async function fetchJwks(baseUrl, options = {}) {
|
|
244
386
|
const now = Date.now();
|
|
245
|
-
if (cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
|
|
387
|
+
if (!options.forceRefresh && cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
|
|
246
388
|
return cachedJwks.jwks;
|
|
247
389
|
}
|
|
390
|
+
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
391
|
+
const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
|
|
248
392
|
try {
|
|
249
|
-
const res = await fetch(
|
|
393
|
+
const res = await fetch(jwksUri, options.forceRefresh ? { cache: "no-cache" } : void 0);
|
|
250
394
|
if (!res.ok) return null;
|
|
251
395
|
const jwks = await res.json();
|
|
252
396
|
cachedJwks = { baseUrl, jwks, fetchedAt: now };
|
|
@@ -255,24 +399,13 @@ async function fetchJwks(baseUrl) {
|
|
|
255
399
|
return null;
|
|
256
400
|
}
|
|
257
401
|
}
|
|
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) {
|
|
402
|
+
async function verifyRs256Signature(token, jwk) {
|
|
272
403
|
try {
|
|
273
404
|
const [headerB64, payloadB64, signature] = token.split(".");
|
|
274
405
|
if (!signature || !jwk.n || !jwk.e) return false;
|
|
275
|
-
const
|
|
406
|
+
const c = getCrypto();
|
|
407
|
+
if (!c) return false;
|
|
408
|
+
const cryptoKey = await c.subtle.importKey(
|
|
276
409
|
"jwk",
|
|
277
410
|
{ kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: false },
|
|
278
411
|
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
@@ -281,7 +414,7 @@ async function verifyRs256(token, jwk) {
|
|
|
281
414
|
);
|
|
282
415
|
const signatureBytes = base64UrlDecode(signature);
|
|
283
416
|
const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
|
|
284
|
-
return await
|
|
417
|
+
return await c.subtle.verify(
|
|
285
418
|
"RSASSA-PKCS1-v1_5",
|
|
286
419
|
cryptoKey,
|
|
287
420
|
signatureBytes,
|
|
@@ -291,7 +424,20 @@ async function verifyRs256(token, jwk) {
|
|
|
291
424
|
return false;
|
|
292
425
|
}
|
|
293
426
|
}
|
|
294
|
-
async function
|
|
427
|
+
async function computeAtHash(accessToken) {
|
|
428
|
+
const c = getCrypto();
|
|
429
|
+
if (!c) return "";
|
|
430
|
+
const hash = await c.subtle.digest("SHA-256", new TextEncoder().encode(accessToken));
|
|
431
|
+
const bytes = new Uint8Array(hash);
|
|
432
|
+
const half = bytes.slice(0, bytes.length / 2);
|
|
433
|
+
let binary = "";
|
|
434
|
+
for (const b of half) {
|
|
435
|
+
binary += String.fromCharCode(b);
|
|
436
|
+
}
|
|
437
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
438
|
+
}
|
|
439
|
+
async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
|
|
440
|
+
const { rejectHs256WhenRs256Available = true } = options;
|
|
295
441
|
const header = decodeJwtHeader(idToken);
|
|
296
442
|
if (!header) {
|
|
297
443
|
throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
|
|
@@ -300,54 +446,91 @@ async function validateIdToken(idToken, expectedIssuer, expectedClientId, access
|
|
|
300
446
|
if (typeof alg !== "string" || alg !== "RS256" && alg !== "HS256") {
|
|
301
447
|
throw new SsoError("id_token_unsupported_alg", `\u4E0D\u652F\u6301\u7684 ID Token \u7B7E\u540D\u7B97\u6CD5: ${alg}`);
|
|
302
448
|
}
|
|
449
|
+
const baseUrl = normalizeIssuer(expectedIssuer);
|
|
450
|
+
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
451
|
+
const normalizedIssuer = normalizeIssuer(discovery?.issuer || baseUrl);
|
|
303
452
|
if (alg === "RS256") {
|
|
304
|
-
const jwks = await fetchJwks(
|
|
453
|
+
const jwks = await fetchJwks(baseUrl);
|
|
305
454
|
if (!jwks) {
|
|
306
455
|
throw new SsoError("id_token_invalid_signature", "\u65E0\u6CD5\u83B7\u53D6 JWKS \u9A8C\u8BC1 ID Token \u7B7E\u540D");
|
|
307
456
|
}
|
|
308
457
|
const kid = typeof header.kid === "string" ? header.kid : void 0;
|
|
309
|
-
const
|
|
458
|
+
const matchCandidates = (set) => set.keys.filter(
|
|
310
459
|
(k) => k.kty === "RSA" && k.alg === "RS256" && k.use === "sig" && (kid ? k.kid === kid : true)
|
|
311
460
|
);
|
|
312
|
-
|
|
461
|
+
const verifyAny = async (keys) => {
|
|
462
|
+
for (const key of keys) {
|
|
463
|
+
if (await verifyRs256Signature(idToken, key)) return true;
|
|
464
|
+
}
|
|
465
|
+
return false;
|
|
466
|
+
};
|
|
467
|
+
let candidates = matchCandidates(jwks);
|
|
468
|
+
let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
|
|
469
|
+
if (candidates.length === 0 || !validSig) {
|
|
470
|
+
const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });
|
|
471
|
+
if (freshJwks) {
|
|
472
|
+
candidates = matchCandidates(freshJwks);
|
|
473
|
+
validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (candidates.length === 0) {
|
|
313
477
|
throw new SsoError("id_token_invalid_signature", "JWKS \u4E2D\u672A\u627E\u5230\u5339\u914D\u7684 RS256 \u516C\u94A5");
|
|
314
478
|
}
|
|
315
|
-
const validSig = await verifyRs256(idToken, key);
|
|
316
479
|
if (!validSig) {
|
|
317
480
|
throw new SsoError("id_token_invalid_signature", "ID Token \u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25");
|
|
318
481
|
}
|
|
319
482
|
}
|
|
320
483
|
if (alg === "HS256") {
|
|
321
|
-
|
|
322
|
-
|
|
484
|
+
if (rejectHs256WhenRs256Available) {
|
|
485
|
+
const jwks = await fetchJwks(baseUrl);
|
|
486
|
+
const hasRs256 = jwks?.keys?.some((k) => k.alg === "RS256" && k.use === "sig");
|
|
487
|
+
if (hasRs256) {
|
|
488
|
+
throw new SsoError("id_token_unsupported_alg", "SSO \u4E2D\u5FC3\u5DF2\u914D\u7F6E RS256\uFF0C\u62D2\u7EDD HS256 ID Token");
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
throw new SsoError(
|
|
492
|
+
"id_token_hs256_unsupported",
|
|
493
|
+
"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
494
|
);
|
|
324
495
|
}
|
|
325
496
|
const payload = decodeJwtPayload(idToken);
|
|
326
497
|
if (!payload) {
|
|
327
498
|
throw new SsoError("id_token_invalid", "ID Token payload \u89E3\u6790\u5931\u8D25");
|
|
328
499
|
}
|
|
329
|
-
const normalizedExpectedIssuer = normalizeIssuer(expectedIssuer);
|
|
330
500
|
const tokenIssuer = typeof payload.iss === "string" ? normalizeIssuer(payload.iss) : "";
|
|
331
|
-
if (tokenIssuer !==
|
|
501
|
+
if (tokenIssuer !== normalizedIssuer) {
|
|
332
502
|
throw new SsoError("id_token_issuer_mismatch", "ID Token issuer \u4E0D\u5339\u914D");
|
|
333
503
|
}
|
|
334
|
-
|
|
504
|
+
const aud = payload.aud;
|
|
505
|
+
const audList = Array.isArray(aud) ? aud : typeof aud === "string" ? [aud] : [];
|
|
506
|
+
if (!audList.includes(expectedClientId)) {
|
|
335
507
|
throw new SsoError("id_token_audience_mismatch", "ID Token audience \u4E0D\u5339\u914D");
|
|
336
508
|
}
|
|
337
|
-
if (
|
|
509
|
+
if (audList.length > 1 && payload.azp !== expectedClientId) {
|
|
510
|
+
throw new SsoError("id_token_audience_mismatch", "ID Token \u591A audience \u65F6 azp \u5FC5\u987B\u4E3A\u5F53\u524D client");
|
|
511
|
+
}
|
|
512
|
+
if (typeof payload.exp !== "number") {
|
|
513
|
+
throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 exp \u58F0\u660E");
|
|
514
|
+
}
|
|
515
|
+
if (Date.now() >= payload.exp * 1e3 + 6e4) {
|
|
338
516
|
throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
|
|
339
517
|
}
|
|
518
|
+
if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
|
|
519
|
+
throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
|
|
520
|
+
}
|
|
340
521
|
if (typeof payload.sub !== "string" || !payload.sub) {
|
|
341
522
|
throw new SsoError("id_token_missing_sub", "ID Token \u7F3A\u5C11 sub");
|
|
342
523
|
}
|
|
343
524
|
if (typeof payload.at_hash === "string" && payload.at_hash) {
|
|
344
525
|
const actual = await computeAtHash(accessToken);
|
|
345
|
-
if (actual
|
|
526
|
+
if (!timingSafeEqualString(actual, payload.at_hash)) {
|
|
346
527
|
throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
|
|
347
528
|
}
|
|
348
529
|
}
|
|
349
530
|
return { sub: payload.sub };
|
|
350
531
|
}
|
|
532
|
+
|
|
533
|
+
// src/core/SsoClient.ts
|
|
351
534
|
var _SsoClient = class _SsoClient {
|
|
352
535
|
constructor(config) {
|
|
353
536
|
this._discovery = null;
|
|
@@ -416,6 +599,17 @@ var _SsoClient = class _SsoClient {
|
|
|
416
599
|
if (d) return d.userinfo_endpoint;
|
|
417
600
|
return `${this.config.ssoBaseUrl}/api/oauth/userinfo`;
|
|
418
601
|
}
|
|
602
|
+
/**
|
|
603
|
+
* 开放重定向防护:仅保存相对路径或与当前页面同源的 returnUrl,
|
|
604
|
+
* 其余(如 https://evil.com)忽略并告警,防止回调后跳转到钓鱼站点。
|
|
605
|
+
*/
|
|
606
|
+
_saveReturnUrlIfTrusted(returnUrl) {
|
|
607
|
+
if (isTrustedReturnUrl(returnUrl, window.location.origin)) {
|
|
608
|
+
saveReturnUrl(returnUrl, this.config.clientId);
|
|
609
|
+
} else {
|
|
610
|
+
console.warn(`[SSO SDK] returnUrl \u672A\u901A\u8FC7\u540C\u6E90\u6821\u9A8C\uFF0C\u5DF2\u5FFD\u7565: ${returnUrl}`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
419
613
|
// ============================================
|
|
420
614
|
// 公共 API
|
|
421
615
|
// ============================================
|
|
@@ -425,7 +619,12 @@ var _SsoClient = class _SsoClient {
|
|
|
425
619
|
* 生成 PKCE code_verifier/code_challenge 和 state 参数,
|
|
426
620
|
* 构建 authorize URL,通过 302 跳转到 SSO 登录页。
|
|
427
621
|
*
|
|
428
|
-
* @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage
|
|
622
|
+
* @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
|
|
623
|
+
* 仅允许相对路径或同源绝对 URL,否则忽略并告警)
|
|
624
|
+
*
|
|
625
|
+
* ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
|
|
626
|
+
* state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
|
|
627
|
+
* 同一次登录只使用其中一个入口。
|
|
429
628
|
*/
|
|
430
629
|
async login(returnUrl) {
|
|
431
630
|
const verifier = generateCodeVerifier();
|
|
@@ -434,7 +633,7 @@ var _SsoClient = class _SsoClient {
|
|
|
434
633
|
savePkceVerifier(this.config.clientId, verifier);
|
|
435
634
|
saveOAuthState(state, this.config.clientId);
|
|
436
635
|
if (returnUrl) {
|
|
437
|
-
|
|
636
|
+
this._saveReturnUrlIfTrusted(returnUrl);
|
|
438
637
|
}
|
|
439
638
|
const authorizeEndpoint = await this._getAuthorizeEndpoint();
|
|
440
639
|
const params = new URLSearchParams();
|
|
@@ -451,6 +650,10 @@ var _SsoClient = class _SsoClient {
|
|
|
451
650
|
* 构建登录 URL(不跳转,返回 URL 字符串)
|
|
452
651
|
*
|
|
453
652
|
* 适用于需要手动处理跳转的场景。
|
|
653
|
+
*
|
|
654
|
+
* ⚠️ 不要与 login() 混用:两者都会重新生成并覆盖 sessionStorage 中的
|
|
655
|
+
* state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
|
|
656
|
+
* 同一次登录只使用其中一个入口。
|
|
454
657
|
*/
|
|
455
658
|
async getLoginUrl(returnUrl) {
|
|
456
659
|
const verifier = generateCodeVerifier();
|
|
@@ -458,7 +661,7 @@ var _SsoClient = class _SsoClient {
|
|
|
458
661
|
const state = generateState();
|
|
459
662
|
savePkceVerifier(this.config.clientId, verifier);
|
|
460
663
|
saveOAuthState(state, this.config.clientId);
|
|
461
|
-
if (returnUrl)
|
|
664
|
+
if (returnUrl) this._saveReturnUrlIfTrusted(returnUrl);
|
|
462
665
|
const authorizeEndpoint = await this._getAuthorizeEndpoint();
|
|
463
666
|
const params = new URLSearchParams();
|
|
464
667
|
params.set("response_type", "code");
|
|
@@ -470,11 +673,140 @@ var _SsoClient = class _SsoClient {
|
|
|
470
673
|
params.set("code_challenge_method", "S256");
|
|
471
674
|
return `${authorizeEndpoint}?${params.toString()}`;
|
|
472
675
|
}
|
|
676
|
+
/**
|
|
677
|
+
* 弹窗模式 SSO 登录
|
|
678
|
+
*
|
|
679
|
+
* 打开一个小窗口进行登录,认证完成后窗口自动关闭,
|
|
680
|
+
* 主页面不丢失状态(适用于 SPA 中需要保持表单/浏览上下文的场景)。
|
|
681
|
+
*
|
|
682
|
+
* 流程:
|
|
683
|
+
* 1. window.open() 打开授权 URL 到弹窗
|
|
684
|
+
* 2. 用户在弹窗中完成登录
|
|
685
|
+
* 3. 弹窗加载 CallbackPage 时检测到 window.opener,通过 postMessage 回传回调 URL
|
|
686
|
+
* 4. 主页面收到消息后调用 handleCallback() 交换 token
|
|
687
|
+
* 5. 弹窗自动关闭
|
|
688
|
+
*
|
|
689
|
+
* @param options - 弹窗配置
|
|
690
|
+
* @param options.returnUrl - 登录成功后的返回地址
|
|
691
|
+
* @param options.width - 弹窗宽度(默认 480)
|
|
692
|
+
* @param options.height - 弹窗高度(默认 640)
|
|
693
|
+
* @returns TokenData
|
|
694
|
+
*
|
|
695
|
+
* @example
|
|
696
|
+
* ```typescript
|
|
697
|
+
* const client = new SsoClient({ clientId: "xxx", redirectUri: "https://myapp.com/callback", ssoBaseUrl: "https://nihplod.cn" });
|
|
698
|
+
* try {
|
|
699
|
+
* const tokenData = await client.loginPopup({ returnUrl: "/dashboard" });
|
|
700
|
+
* console.log("登录成功", tokenData);
|
|
701
|
+
* } catch (err) {
|
|
702
|
+
* if (err instanceof SsoError && err.code === "popup_blocked") {
|
|
703
|
+
* // 弹窗被拦截,回退到同页重定向
|
|
704
|
+
* await client.login("/dashboard");
|
|
705
|
+
* }
|
|
706
|
+
* }
|
|
707
|
+
* ```
|
|
708
|
+
*/
|
|
709
|
+
async loginPopup(options = {}) {
|
|
710
|
+
const verifier = generateCodeVerifier();
|
|
711
|
+
const challenge = await generateCodeChallenge(verifier);
|
|
712
|
+
const state = generateState();
|
|
713
|
+
const popupNonce = generateState();
|
|
714
|
+
savePkceVerifier(this.config.clientId, verifier);
|
|
715
|
+
saveOAuthState(state, this.config.clientId);
|
|
716
|
+
savePkceVerifier(`${this.config.clientId}_popup_nonce`, popupNonce);
|
|
717
|
+
if (options.returnUrl) {
|
|
718
|
+
this._saveReturnUrlIfTrusted(options.returnUrl);
|
|
719
|
+
}
|
|
720
|
+
const authorizeEndpoint = await this._getAuthorizeEndpoint();
|
|
721
|
+
const params = new URLSearchParams();
|
|
722
|
+
params.set("response_type", "code");
|
|
723
|
+
params.set("client_id", this.config.clientId);
|
|
724
|
+
params.set("redirect_uri", this.config.redirectUri);
|
|
725
|
+
params.set("scope", this.config.scopes || "openid profile");
|
|
726
|
+
params.set("state", state);
|
|
727
|
+
params.set("code_challenge", challenge);
|
|
728
|
+
params.set("code_challenge_method", "S256");
|
|
729
|
+
params.set("popup_nonce", popupNonce);
|
|
730
|
+
const width = options.width || 480;
|
|
731
|
+
const height = options.height || 640;
|
|
732
|
+
const left = Math.max(0, (window.screen.width - width) / 2);
|
|
733
|
+
const top = Math.max(0, (window.screen.height - height) / 2);
|
|
734
|
+
const popupFeatures = [
|
|
735
|
+
`width=${width}`,
|
|
736
|
+
`height=${height}`,
|
|
737
|
+
`left=${Math.round(left)}`,
|
|
738
|
+
`top=${Math.round(top)}`,
|
|
739
|
+
"resizable=yes",
|
|
740
|
+
"scrollbars=yes",
|
|
741
|
+
"status=yes"
|
|
742
|
+
].join(",");
|
|
743
|
+
const popup = window.open(
|
|
744
|
+
`${authorizeEndpoint}?${params.toString()}`,
|
|
745
|
+
"nihplod_sso_popup",
|
|
746
|
+
popupFeatures
|
|
747
|
+
);
|
|
748
|
+
if (!popup) {
|
|
749
|
+
removePkceVerifier(this.config.clientId);
|
|
750
|
+
removeOAuthState(this.config.clientId);
|
|
751
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
752
|
+
throw new SsoError(
|
|
753
|
+
"popup_blocked",
|
|
754
|
+
"\u5F39\u7A97\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5"
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
const redirectUriOrigin = new URL(this.config.redirectUri).origin;
|
|
758
|
+
try {
|
|
759
|
+
popup.focus();
|
|
760
|
+
return await new Promise((resolve, reject) => {
|
|
761
|
+
let completed = false;
|
|
762
|
+
const handleMessage = (event) => {
|
|
763
|
+
if (completed) return;
|
|
764
|
+
if (event.origin !== redirectUriOrigin) return;
|
|
765
|
+
if (!event.data || event.data.type !== "nihplod_sso_popup_callback") return;
|
|
766
|
+
if (!event.data.callbackUrl) return;
|
|
767
|
+
const savedNonce = getPkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
768
|
+
if (!savedNonce || !timingSafeEqualString(event.data.nonce ?? "", savedNonce)) return;
|
|
769
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
770
|
+
completed = true;
|
|
771
|
+
cleanup();
|
|
772
|
+
if (popup && !popup.closed) {
|
|
773
|
+
popup.close();
|
|
774
|
+
}
|
|
775
|
+
this.handleCallback(event.data.callbackUrl).then(resolve).catch(reject);
|
|
776
|
+
};
|
|
777
|
+
const pollTimer = setInterval(() => {
|
|
778
|
+
if (popup.closed) {
|
|
779
|
+
if (!completed) {
|
|
780
|
+
completed = true;
|
|
781
|
+
cleanup();
|
|
782
|
+
reject(
|
|
783
|
+
new SsoError(
|
|
784
|
+
"popup_closed",
|
|
785
|
+
"\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED"
|
|
786
|
+
)
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}, 500);
|
|
791
|
+
const cleanup = () => {
|
|
792
|
+
clearInterval(pollTimer);
|
|
793
|
+
window.removeEventListener("message", handleMessage);
|
|
794
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
795
|
+
};
|
|
796
|
+
window.addEventListener("message", handleMessage);
|
|
797
|
+
});
|
|
798
|
+
} catch (err) {
|
|
799
|
+
removePkceVerifier(this.config.clientId);
|
|
800
|
+
removeOAuthState(this.config.clientId);
|
|
801
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
802
|
+
throw err;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
473
805
|
/**
|
|
474
806
|
* 处理 OAuth 回调
|
|
475
807
|
*
|
|
476
808
|
* 解析回调 URL,校验 state 参数,用授权码交换 token。
|
|
477
|
-
* 成功后 token 自动保存到 sessionStorage
|
|
809
|
+
* 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
|
|
478
810
|
*
|
|
479
811
|
* @param callbackUrl - 完整的回调 URL(window.location.href)
|
|
480
812
|
* @returns TokenData 或 null
|
|
@@ -484,23 +816,27 @@ var _SsoClient = class _SsoClient {
|
|
|
484
816
|
const params = url.searchParams;
|
|
485
817
|
const error = params.get("error");
|
|
486
818
|
if (error) {
|
|
819
|
+
removeOAuthState(this.config.clientId);
|
|
820
|
+
removePkceVerifier(this.config.clientId);
|
|
487
821
|
const desc = params.get("error_description") || error;
|
|
488
|
-
throw new SsoError(
|
|
822
|
+
throw new SsoError(mapOAuthErrorToSsoCode(error), `\u6388\u6743\u5931\u8D25: ${desc}`);
|
|
489
823
|
}
|
|
490
824
|
const code = params.get("code");
|
|
491
825
|
const returnedState = params.get("state");
|
|
492
826
|
if (!code) {
|
|
827
|
+
removeOAuthState(this.config.clientId);
|
|
828
|
+
removePkceVerifier(this.config.clientId);
|
|
493
829
|
throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
|
|
494
830
|
}
|
|
495
831
|
const savedState = getOAuthState(this.config.clientId);
|
|
496
|
-
if (!savedState || savedState
|
|
832
|
+
if (!savedState || !timingSafeEqualString(savedState, returnedState ?? "")) {
|
|
497
833
|
removeOAuthState(this.config.clientId);
|
|
834
|
+
removePkceVerifier(this.config.clientId);
|
|
498
835
|
throw new SsoError(
|
|
499
836
|
"state_mismatch",
|
|
500
837
|
"State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
|
|
501
838
|
);
|
|
502
839
|
}
|
|
503
|
-
removeOAuthState(this.config.clientId);
|
|
504
840
|
const verifier = getPkceVerifier(this.config.clientId);
|
|
505
841
|
if (!verifier) {
|
|
506
842
|
throw new SsoError(
|
|
@@ -508,7 +844,6 @@ var _SsoClient = class _SsoClient {
|
|
|
508
844
|
"code_verifier \u4E0D\u5B58\u5728\uFF08\u53EF\u80FD\u5DF2\u8FC7\u671F\u6216\u6765\u81EA\u5176\u4ED6\u6807\u7B7E\u9875\uFF09"
|
|
509
845
|
);
|
|
510
846
|
}
|
|
511
|
-
removePkceVerifier(this.config.clientId);
|
|
512
847
|
const tokenEndpoint = await this._getTokenEndpoint();
|
|
513
848
|
const body = new URLSearchParams();
|
|
514
849
|
body.set("grant_type", "authorization_code");
|
|
@@ -535,8 +870,9 @@ var _SsoClient = class _SsoClient {
|
|
|
535
870
|
errData = await res.json();
|
|
536
871
|
} catch {
|
|
537
872
|
}
|
|
873
|
+
const serverError = errData.error || "";
|
|
538
874
|
throw new SsoError(
|
|
539
|
-
|
|
875
|
+
mapOAuthErrorToSsoCode(serverError),
|
|
540
876
|
errData.error_description || `Token \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}`
|
|
541
877
|
);
|
|
542
878
|
}
|
|
@@ -545,15 +881,17 @@ var _SsoClient = class _SsoClient {
|
|
|
545
881
|
try {
|
|
546
882
|
await validateIdToken(
|
|
547
883
|
data.id_token,
|
|
884
|
+
data.access_token,
|
|
548
885
|
this.config.ssoBaseUrl,
|
|
549
|
-
this.config.clientId
|
|
550
|
-
data.access_token
|
|
886
|
+
this.config.clientId
|
|
551
887
|
);
|
|
552
888
|
} catch (err) {
|
|
553
889
|
removeTokenData(this.config.clientId);
|
|
554
890
|
throw err;
|
|
555
891
|
}
|
|
556
892
|
}
|
|
893
|
+
removeOAuthState(this.config.clientId);
|
|
894
|
+
removePkceVerifier(this.config.clientId);
|
|
557
895
|
const now = Date.now();
|
|
558
896
|
const tokenData = {
|
|
559
897
|
access_token: data.access_token,
|
|
@@ -596,15 +934,24 @@ var _SsoClient = class _SsoClient {
|
|
|
596
934
|
if (this.config.clientSecret) {
|
|
597
935
|
body.set("client_secret", this.config.clientSecret);
|
|
598
936
|
}
|
|
599
|
-
let res;
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
937
|
+
let res = null;
|
|
938
|
+
let lastErr;
|
|
939
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
940
|
+
try {
|
|
941
|
+
res = await fetch(tokenEndpoint, {
|
|
942
|
+
method: "POST",
|
|
943
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
944
|
+
body: body.toString()
|
|
945
|
+
});
|
|
946
|
+
lastErr = null;
|
|
947
|
+
break;
|
|
948
|
+
} catch (err) {
|
|
949
|
+
lastErr = err;
|
|
950
|
+
if (attempt < 1) await new Promise((r) => setTimeout(r, 1e3));
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
if (lastErr || !res) {
|
|
954
|
+
throw new SsoError("network_error", "\u5237\u65B0 Token \u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", lastErr);
|
|
608
955
|
}
|
|
609
956
|
if (!res.ok) {
|
|
610
957
|
let errData = {};
|
|
@@ -617,17 +964,31 @@ var _SsoClient = class _SsoClient {
|
|
|
617
964
|
removeTokenData(this.config.clientId);
|
|
618
965
|
}
|
|
619
966
|
throw new SsoError(
|
|
620
|
-
errorCode
|
|
967
|
+
mapOAuthErrorToSsoCode(errorCode, "refresh"),
|
|
621
968
|
errData.error_description || `\u5237\u65B0 Token \u5931\u8D25: HTTP ${res.status}`
|
|
622
969
|
);
|
|
623
970
|
}
|
|
624
971
|
const data = await res.json();
|
|
972
|
+
if (data.id_token) {
|
|
973
|
+
try {
|
|
974
|
+
await validateIdToken(
|
|
975
|
+
data.id_token,
|
|
976
|
+
data.access_token,
|
|
977
|
+
this.config.ssoBaseUrl,
|
|
978
|
+
this.config.clientId
|
|
979
|
+
);
|
|
980
|
+
} catch (err) {
|
|
981
|
+
removeTokenData(this.config.clientId);
|
|
982
|
+
throw err;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
625
985
|
const now = Date.now();
|
|
626
986
|
const tokenData = {
|
|
627
987
|
access_token: data.access_token,
|
|
628
988
|
token_type: data.token_type,
|
|
629
989
|
expires_in: data.expires_in,
|
|
630
|
-
|
|
990
|
+
// RFC 6749 §6:刷新响应可省略 refresh_token,此时沿用旧值
|
|
991
|
+
refresh_token: data.refresh_token || current.refresh_token,
|
|
631
992
|
id_token: data.id_token,
|
|
632
993
|
issued_at: now,
|
|
633
994
|
expires_at: now + data.expires_in * 1e3
|
|
@@ -699,7 +1060,9 @@ var _SsoClient = class _SsoClient {
|
|
|
699
1060
|
* 登出
|
|
700
1061
|
*
|
|
701
1062
|
* 清除本地所有 token 和临时数据,并尝试撤销服务端 refresh_token。
|
|
702
|
-
* @param redirectToSso - 是否重定向到 SSO 登出页(默认 false
|
|
1063
|
+
* @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)。
|
|
1064
|
+
* 为 true 时携带 state 参数(已保存到 sessionStorage),
|
|
1065
|
+
* 回跳页面应调用 validateLogoutState() 校验防登出 CSRF。
|
|
703
1066
|
*/
|
|
704
1067
|
async logout(redirectToSso = false) {
|
|
705
1068
|
const tokenData = getTokenData(this.config.clientId);
|
|
@@ -728,23 +1091,50 @@ var _SsoClient = class _SsoClient {
|
|
|
728
1091
|
}
|
|
729
1092
|
if (redirectToSso) {
|
|
730
1093
|
const discovery = await this._getDiscovery();
|
|
731
|
-
const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/
|
|
1094
|
+
const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/api/oauth/end-session`;
|
|
732
1095
|
const logoutUrl = new URL(endSessionEndpoint);
|
|
733
1096
|
logoutUrl.searchParams.set("client_id", this.config.clientId);
|
|
734
|
-
|
|
1097
|
+
const postLogoutUri = this.config.postLogoutRedirectUri || this.config.redirectUri;
|
|
1098
|
+
if (postLogoutUri) {
|
|
735
1099
|
logoutUrl.searchParams.set(
|
|
736
1100
|
"post_logout_redirect_uri",
|
|
737
|
-
|
|
1101
|
+
postLogoutUri
|
|
738
1102
|
);
|
|
739
1103
|
}
|
|
740
1104
|
if (idTokenHint) {
|
|
741
1105
|
logoutUrl.searchParams.set("id_token_hint", idTokenHint);
|
|
742
1106
|
}
|
|
743
1107
|
const state = generateState();
|
|
1108
|
+
saveLogoutState(state, this.config.clientId);
|
|
744
1109
|
logoutUrl.searchParams.set("state", state);
|
|
745
1110
|
window.location.href = logoutUrl.toString();
|
|
746
1111
|
}
|
|
747
1112
|
}
|
|
1113
|
+
/**
|
|
1114
|
+
* 校验 RP-Initiated Logout 回跳的 state 参数(登出 CSRF 防护)
|
|
1115
|
+
*
|
|
1116
|
+
* 在 post_logout_redirect_uri 指向的页面加载时调用;
|
|
1117
|
+
* 仅在 URL 携带 state 且与 logout(redirectToSso=true) 保存的值一致时返回 true,
|
|
1118
|
+
* 校验后清除已保存的 logout state(一次性)。
|
|
1119
|
+
*
|
|
1120
|
+
* @param url - 当前页面完整 URL(window.location.href)
|
|
1121
|
+
*
|
|
1122
|
+
* @example
|
|
1123
|
+
* ```typescript
|
|
1124
|
+
* if (sso.validateLogoutState(window.location.href)) {
|
|
1125
|
+
* // 来自 SSO 登出的可信回跳
|
|
1126
|
+
* }
|
|
1127
|
+
* ```
|
|
1128
|
+
*/
|
|
1129
|
+
validateLogoutState(url) {
|
|
1130
|
+
const returnedState = new URL(url).searchParams.get("state");
|
|
1131
|
+
if (!returnedState) return false;
|
|
1132
|
+
const savedState = getLogoutState(this.config.clientId);
|
|
1133
|
+
if (!savedState) return false;
|
|
1134
|
+
if (!timingSafeEqualString(savedState, returnedState)) return false;
|
|
1135
|
+
removeLogoutState(this.config.clientId);
|
|
1136
|
+
return true;
|
|
1137
|
+
}
|
|
748
1138
|
/**
|
|
749
1139
|
* 获取 OIDC Discovery 文档
|
|
750
1140
|
*
|
|
@@ -767,22 +1157,29 @@ var SsoClient = _SsoClient;
|
|
|
767
1157
|
SsoError,
|
|
768
1158
|
clearAllSsoData,
|
|
769
1159
|
clearVerifiersForClients,
|
|
1160
|
+
createSecureStorage,
|
|
770
1161
|
generateCodeChallenge,
|
|
771
1162
|
generateCodeVerifier,
|
|
772
1163
|
generateState,
|
|
1164
|
+
getLogoutState,
|
|
773
1165
|
getOAuthState,
|
|
774
1166
|
getPkceVerifier,
|
|
775
1167
|
getReturnUrl,
|
|
776
1168
|
getTokenData,
|
|
777
1169
|
getTokenStorage,
|
|
1170
|
+
isTrustedReturnUrl,
|
|
1171
|
+
mapOAuthErrorToSsoCode,
|
|
1172
|
+
removeLogoutState,
|
|
778
1173
|
removeOAuthState,
|
|
779
1174
|
removePkceVerifier,
|
|
780
1175
|
removeReturnUrl,
|
|
781
1176
|
removeTokenData,
|
|
1177
|
+
saveLogoutState,
|
|
782
1178
|
saveOAuthState,
|
|
783
1179
|
savePkceVerifier,
|
|
784
1180
|
saveReturnUrl,
|
|
785
1181
|
saveTokenData,
|
|
786
|
-
setTokenStorage
|
|
1182
|
+
setTokenStorage,
|
|
1183
|
+
timingSafeEqualString
|
|
787
1184
|
});
|
|
788
1185
|
//# sourceMappingURL=index.js.map
|