@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.mjs
CHANGED
|
@@ -1,4 +1,24 @@
|
|
|
1
1
|
// src/core/errors.ts
|
|
2
|
+
function mapOAuthErrorToSsoCode(oauthError, context = "token_exchange") {
|
|
3
|
+
switch (oauthError) {
|
|
4
|
+
case "invalid_grant":
|
|
5
|
+
return context === "refresh" ? "session_expired" : "authorization_code_expired";
|
|
6
|
+
case "invalid_client":
|
|
7
|
+
return "client_disabled";
|
|
8
|
+
case "access_denied":
|
|
9
|
+
return "user_denied_authorization";
|
|
10
|
+
case "server_error":
|
|
11
|
+
return "sso_server_error";
|
|
12
|
+
case "rate_limited":
|
|
13
|
+
return "rate_limited";
|
|
14
|
+
case "invalid_scope":
|
|
15
|
+
case "invalid_request":
|
|
16
|
+
case "unauthorized_client":
|
|
17
|
+
case "unsupported_grant_type":
|
|
18
|
+
default:
|
|
19
|
+
return "token_request_failed";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
2
22
|
var SsoError = class extends Error {
|
|
3
23
|
constructor(code, description, cause) {
|
|
4
24
|
super(`[SSO SDK] ${code}: ${description}`);
|
|
@@ -63,6 +83,7 @@ var TOKEN_KEY = "token";
|
|
|
63
83
|
var VERIFIER_KEY_PREFIX = "pkce_verifier_";
|
|
64
84
|
var STATE_KEY = "oauth_state";
|
|
65
85
|
var RETURN_URL_KEY = "return_url";
|
|
86
|
+
var LOGOUT_STATE_KEY = "logout_state";
|
|
66
87
|
function buildKey(base, clientId) {
|
|
67
88
|
return clientId ? `${base}:${clientId}` : base;
|
|
68
89
|
}
|
|
@@ -81,7 +102,58 @@ function createMemoryStorageAdapter() {
|
|
|
81
102
|
};
|
|
82
103
|
}
|
|
83
104
|
var memoryStorageAdapter = createMemoryStorageAdapter();
|
|
84
|
-
var
|
|
105
|
+
var localStorageAdapter = {
|
|
106
|
+
get(key) {
|
|
107
|
+
if (typeof localStorage === "undefined") return null;
|
|
108
|
+
return localStorage.getItem(STORAGE_PREFIX + key);
|
|
109
|
+
},
|
|
110
|
+
set(key, value) {
|
|
111
|
+
if (typeof localStorage === "undefined") return;
|
|
112
|
+
try {
|
|
113
|
+
localStorage.setItem(STORAGE_PREFIX + key, value);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.warn(
|
|
116
|
+
`[SSO SDK] localStorage \u5199\u5165\u5931\u8D25\uFF08${err instanceof Error ? err.name : String(err)}\uFF09\uFF0C\u6570\u636E\u672A\u6301\u4E45\u5316`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
remove(key) {
|
|
121
|
+
if (typeof localStorage === "undefined") return;
|
|
122
|
+
localStorage.removeItem(STORAGE_PREFIX + key);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
function createSessionStorageAdapter() {
|
|
126
|
+
const fallback = /* @__PURE__ */ new Map();
|
|
127
|
+
return {
|
|
128
|
+
get(key) {
|
|
129
|
+
if (typeof sessionStorage !== "undefined") {
|
|
130
|
+
return sessionStorage.getItem(STORAGE_PREFIX + key) ?? fallback.get(key) ?? null;
|
|
131
|
+
}
|
|
132
|
+
return fallback.get(key) ?? null;
|
|
133
|
+
},
|
|
134
|
+
set(key, value) {
|
|
135
|
+
if (typeof sessionStorage === "undefined") {
|
|
136
|
+
fallback.set(key, value);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
sessionStorage.setItem(STORAGE_PREFIX + key, value);
|
|
141
|
+
} catch {
|
|
142
|
+
fallback.set(key, value);
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
remove(key) {
|
|
146
|
+
fallback.delete(key);
|
|
147
|
+
if (typeof sessionStorage === "undefined") return;
|
|
148
|
+
sessionStorage.removeItem(STORAGE_PREFIX + key);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
var _transient = createSessionStorageAdapter();
|
|
153
|
+
function createSecureStorage(options = {}) {
|
|
154
|
+
return options.persist ? localStorageAdapter : createSessionStorageAdapter();
|
|
155
|
+
}
|
|
156
|
+
var _storage = createSessionStorageAdapter();
|
|
85
157
|
function setTokenStorage(storage) {
|
|
86
158
|
_storage = storage;
|
|
87
159
|
}
|
|
@@ -104,60 +176,107 @@ function removeTokenData(clientId) {
|
|
|
104
176
|
_storage.remove(buildKey(TOKEN_KEY, clientId));
|
|
105
177
|
}
|
|
106
178
|
function savePkceVerifier(clientId, verifier) {
|
|
107
|
-
|
|
179
|
+
_transient.set(VERIFIER_KEY_PREFIX + clientId, verifier);
|
|
108
180
|
}
|
|
109
181
|
function getPkceVerifier(clientId) {
|
|
110
|
-
return
|
|
182
|
+
return _transient.get(VERIFIER_KEY_PREFIX + clientId);
|
|
111
183
|
}
|
|
112
184
|
function removePkceVerifier(clientId) {
|
|
113
|
-
|
|
185
|
+
_transient.remove(VERIFIER_KEY_PREFIX + clientId);
|
|
114
186
|
}
|
|
115
187
|
function saveOAuthState(state, clientId) {
|
|
116
|
-
|
|
188
|
+
_transient.set(buildKey(STATE_KEY, clientId), state);
|
|
117
189
|
}
|
|
118
190
|
function getOAuthState(clientId) {
|
|
119
|
-
return
|
|
191
|
+
return _transient.get(buildKey(STATE_KEY, clientId));
|
|
120
192
|
}
|
|
121
193
|
function removeOAuthState(clientId) {
|
|
122
|
-
|
|
194
|
+
_transient.remove(buildKey(STATE_KEY, clientId));
|
|
195
|
+
}
|
|
196
|
+
function saveLogoutState(state, clientId) {
|
|
197
|
+
_transient.set(buildKey(LOGOUT_STATE_KEY, clientId), state);
|
|
198
|
+
}
|
|
199
|
+
function getLogoutState(clientId) {
|
|
200
|
+
return _transient.get(buildKey(LOGOUT_STATE_KEY, clientId));
|
|
201
|
+
}
|
|
202
|
+
function removeLogoutState(clientId) {
|
|
203
|
+
_transient.remove(buildKey(LOGOUT_STATE_KEY, clientId));
|
|
123
204
|
}
|
|
124
205
|
function saveReturnUrl(url, clientId) {
|
|
125
|
-
|
|
206
|
+
_transient.set(buildKey(RETURN_URL_KEY, clientId), url);
|
|
126
207
|
}
|
|
127
208
|
function getReturnUrl(clientId) {
|
|
128
|
-
return
|
|
209
|
+
return _transient.get(buildKey(RETURN_URL_KEY, clientId));
|
|
129
210
|
}
|
|
130
211
|
function removeReturnUrl(clientId) {
|
|
131
|
-
|
|
212
|
+
_transient.remove(buildKey(RETURN_URL_KEY, clientId));
|
|
132
213
|
}
|
|
133
214
|
function clearAllSsoData(clientId) {
|
|
134
215
|
if (clientId) {
|
|
135
216
|
removeTokenData(clientId);
|
|
136
217
|
removeOAuthState(clientId);
|
|
137
218
|
removeReturnUrl(clientId);
|
|
219
|
+
removeLogoutState(clientId);
|
|
138
220
|
removePkceVerifier(clientId);
|
|
221
|
+
removePkceVerifier(`${clientId}_popup_nonce`);
|
|
139
222
|
return;
|
|
140
223
|
}
|
|
141
224
|
removeTokenData();
|
|
142
225
|
removeOAuthState();
|
|
143
226
|
removeReturnUrl();
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
227
|
+
removeLogoutState();
|
|
228
|
+
const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
|
|
229
|
+
const stores = [
|
|
230
|
+
typeof sessionStorage !== "undefined" ? sessionStorage : null,
|
|
231
|
+
typeof localStorage !== "undefined" ? localStorage : null
|
|
232
|
+
];
|
|
233
|
+
for (const store of stores) {
|
|
234
|
+
if (!store) continue;
|
|
235
|
+
const keys = [];
|
|
236
|
+
for (let i = 0; i < store.length; i++) {
|
|
237
|
+
const key = store.key(i);
|
|
238
|
+
if (key?.startsWith(prefix)) keys.push(key);
|
|
239
|
+
}
|
|
240
|
+
for (const key of keys) {
|
|
241
|
+
_transient.remove(key.slice(STORAGE_PREFIX.length));
|
|
242
|
+
store.removeItem(key);
|
|
151
243
|
}
|
|
152
244
|
}
|
|
153
245
|
}
|
|
154
246
|
function clearVerifiersForClients(clientIds) {
|
|
155
247
|
for (const clientId of clientIds) {
|
|
156
|
-
|
|
248
|
+
_transient.remove(VERIFIER_KEY_PREFIX + clientId);
|
|
157
249
|
}
|
|
158
250
|
}
|
|
159
251
|
|
|
160
|
-
// src/core/
|
|
252
|
+
// src/core/security.ts
|
|
253
|
+
function isTrustedReturnUrl(url, currentOrigin) {
|
|
254
|
+
if (!url) return false;
|
|
255
|
+
if (url.includes("\\")) return false;
|
|
256
|
+
if (url.startsWith("/") && !url.startsWith("//")) return true;
|
|
257
|
+
try {
|
|
258
|
+
return new URL(url).origin === currentOrigin;
|
|
259
|
+
} catch {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
function timingSafeEqualString(a, b) {
|
|
264
|
+
const ba = new TextEncoder().encode(a);
|
|
265
|
+
const bb = new TextEncoder().encode(b);
|
|
266
|
+
if (ba.length === 0 || bb.length === 0) return ba.length === bb.length;
|
|
267
|
+
const len = Math.max(ba.length, bb.length);
|
|
268
|
+
let diff = ba.length ^ bb.length;
|
|
269
|
+
for (let i = 0; i < len; i++) {
|
|
270
|
+
diff |= ba[i % ba.length] ^ bb[i % bb.length];
|
|
271
|
+
}
|
|
272
|
+
return diff === 0;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// src/core/id-token.ts
|
|
276
|
+
var _crypto = typeof crypto !== "undefined" && crypto.subtle ? crypto : null;
|
|
277
|
+
function getCrypto() {
|
|
278
|
+
return _crypto ?? null;
|
|
279
|
+
}
|
|
161
280
|
function base64UrlDecode(input) {
|
|
162
281
|
const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
163
282
|
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
|
|
@@ -192,14 +311,32 @@ function normalizeIssuer(url) {
|
|
|
192
311
|
return url.replace(/\/+$/, "");
|
|
193
312
|
}
|
|
194
313
|
var cachedJwks = null;
|
|
314
|
+
var cachedDiscovery = null;
|
|
195
315
|
var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
196
|
-
async function
|
|
316
|
+
async function fetchDiscoveryDoc(baseUrl) {
|
|
317
|
+
const now = Date.now();
|
|
318
|
+
if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
|
|
319
|
+
return cachedDiscovery.doc;
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`);
|
|
323
|
+
const doc = res.ok ? await res.json() : null;
|
|
324
|
+
cachedDiscovery = { baseUrl, doc, fetchedAt: now };
|
|
325
|
+
return doc;
|
|
326
|
+
} catch {
|
|
327
|
+
cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
async function fetchJwks(baseUrl, options = {}) {
|
|
197
332
|
const now = Date.now();
|
|
198
|
-
if (cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
|
|
333
|
+
if (!options.forceRefresh && cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
|
|
199
334
|
return cachedJwks.jwks;
|
|
200
335
|
}
|
|
336
|
+
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
337
|
+
const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
|
|
201
338
|
try {
|
|
202
|
-
const res = await fetch(
|
|
339
|
+
const res = await fetch(jwksUri, options.forceRefresh ? { cache: "no-cache" } : void 0);
|
|
203
340
|
if (!res.ok) return null;
|
|
204
341
|
const jwks = await res.json();
|
|
205
342
|
cachedJwks = { baseUrl, jwks, fetchedAt: now };
|
|
@@ -208,24 +345,13 @@ async function fetchJwks(baseUrl) {
|
|
|
208
345
|
return null;
|
|
209
346
|
}
|
|
210
347
|
}
|
|
211
|
-
async function
|
|
212
|
-
const hash = await crypto.subtle.digest(
|
|
213
|
-
"SHA-256",
|
|
214
|
-
new TextEncoder().encode(accessToken)
|
|
215
|
-
);
|
|
216
|
-
const bytes = new Uint8Array(hash);
|
|
217
|
-
const half = bytes.slice(0, bytes.length / 2);
|
|
218
|
-
let binary = "";
|
|
219
|
-
for (const b of half) {
|
|
220
|
-
binary += String.fromCharCode(b);
|
|
221
|
-
}
|
|
222
|
-
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
223
|
-
}
|
|
224
|
-
async function verifyRs256(token, jwk) {
|
|
348
|
+
async function verifyRs256Signature(token, jwk) {
|
|
225
349
|
try {
|
|
226
350
|
const [headerB64, payloadB64, signature] = token.split(".");
|
|
227
351
|
if (!signature || !jwk.n || !jwk.e) return false;
|
|
228
|
-
const
|
|
352
|
+
const c = getCrypto();
|
|
353
|
+
if (!c) return false;
|
|
354
|
+
const cryptoKey = await c.subtle.importKey(
|
|
229
355
|
"jwk",
|
|
230
356
|
{ kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: false },
|
|
231
357
|
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
@@ -234,7 +360,7 @@ async function verifyRs256(token, jwk) {
|
|
|
234
360
|
);
|
|
235
361
|
const signatureBytes = base64UrlDecode(signature);
|
|
236
362
|
const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
|
|
237
|
-
return await
|
|
363
|
+
return await c.subtle.verify(
|
|
238
364
|
"RSASSA-PKCS1-v1_5",
|
|
239
365
|
cryptoKey,
|
|
240
366
|
signatureBytes,
|
|
@@ -244,7 +370,20 @@ async function verifyRs256(token, jwk) {
|
|
|
244
370
|
return false;
|
|
245
371
|
}
|
|
246
372
|
}
|
|
247
|
-
async function
|
|
373
|
+
async function computeAtHash(accessToken) {
|
|
374
|
+
const c = getCrypto();
|
|
375
|
+
if (!c) return "";
|
|
376
|
+
const hash = await c.subtle.digest("SHA-256", new TextEncoder().encode(accessToken));
|
|
377
|
+
const bytes = new Uint8Array(hash);
|
|
378
|
+
const half = bytes.slice(0, bytes.length / 2);
|
|
379
|
+
let binary = "";
|
|
380
|
+
for (const b of half) {
|
|
381
|
+
binary += String.fromCharCode(b);
|
|
382
|
+
}
|
|
383
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
384
|
+
}
|
|
385
|
+
async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
|
|
386
|
+
const { rejectHs256WhenRs256Available = true } = options;
|
|
248
387
|
const header = decodeJwtHeader(idToken);
|
|
249
388
|
if (!header) {
|
|
250
389
|
throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
|
|
@@ -253,54 +392,91 @@ async function validateIdToken(idToken, expectedIssuer, expectedClientId, access
|
|
|
253
392
|
if (typeof alg !== "string" || alg !== "RS256" && alg !== "HS256") {
|
|
254
393
|
throw new SsoError("id_token_unsupported_alg", `\u4E0D\u652F\u6301\u7684 ID Token \u7B7E\u540D\u7B97\u6CD5: ${alg}`);
|
|
255
394
|
}
|
|
395
|
+
const baseUrl = normalizeIssuer(expectedIssuer);
|
|
396
|
+
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
397
|
+
const normalizedIssuer = normalizeIssuer(discovery?.issuer || baseUrl);
|
|
256
398
|
if (alg === "RS256") {
|
|
257
|
-
const jwks = await fetchJwks(
|
|
399
|
+
const jwks = await fetchJwks(baseUrl);
|
|
258
400
|
if (!jwks) {
|
|
259
401
|
throw new SsoError("id_token_invalid_signature", "\u65E0\u6CD5\u83B7\u53D6 JWKS \u9A8C\u8BC1 ID Token \u7B7E\u540D");
|
|
260
402
|
}
|
|
261
403
|
const kid = typeof header.kid === "string" ? header.kid : void 0;
|
|
262
|
-
const
|
|
404
|
+
const matchCandidates = (set) => set.keys.filter(
|
|
263
405
|
(k) => k.kty === "RSA" && k.alg === "RS256" && k.use === "sig" && (kid ? k.kid === kid : true)
|
|
264
406
|
);
|
|
265
|
-
|
|
407
|
+
const verifyAny = async (keys) => {
|
|
408
|
+
for (const key of keys) {
|
|
409
|
+
if (await verifyRs256Signature(idToken, key)) return true;
|
|
410
|
+
}
|
|
411
|
+
return false;
|
|
412
|
+
};
|
|
413
|
+
let candidates = matchCandidates(jwks);
|
|
414
|
+
let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
|
|
415
|
+
if (candidates.length === 0 || !validSig) {
|
|
416
|
+
const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });
|
|
417
|
+
if (freshJwks) {
|
|
418
|
+
candidates = matchCandidates(freshJwks);
|
|
419
|
+
validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (candidates.length === 0) {
|
|
266
423
|
throw new SsoError("id_token_invalid_signature", "JWKS \u4E2D\u672A\u627E\u5230\u5339\u914D\u7684 RS256 \u516C\u94A5");
|
|
267
424
|
}
|
|
268
|
-
const validSig = await verifyRs256(idToken, key);
|
|
269
425
|
if (!validSig) {
|
|
270
426
|
throw new SsoError("id_token_invalid_signature", "ID Token \u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25");
|
|
271
427
|
}
|
|
272
428
|
}
|
|
273
429
|
if (alg === "HS256") {
|
|
274
|
-
|
|
275
|
-
|
|
430
|
+
if (rejectHs256WhenRs256Available) {
|
|
431
|
+
const jwks = await fetchJwks(baseUrl);
|
|
432
|
+
const hasRs256 = jwks?.keys?.some((k) => k.alg === "RS256" && k.use === "sig");
|
|
433
|
+
if (hasRs256) {
|
|
434
|
+
throw new SsoError("id_token_unsupported_alg", "SSO \u4E2D\u5FC3\u5DF2\u914D\u7F6E RS256\uFF0C\u62D2\u7EDD HS256 ID Token");
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
throw new SsoError(
|
|
438
|
+
"id_token_hs256_unsupported",
|
|
439
|
+
"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"
|
|
276
440
|
);
|
|
277
441
|
}
|
|
278
442
|
const payload = decodeJwtPayload(idToken);
|
|
279
443
|
if (!payload) {
|
|
280
444
|
throw new SsoError("id_token_invalid", "ID Token payload \u89E3\u6790\u5931\u8D25");
|
|
281
445
|
}
|
|
282
|
-
const normalizedExpectedIssuer = normalizeIssuer(expectedIssuer);
|
|
283
446
|
const tokenIssuer = typeof payload.iss === "string" ? normalizeIssuer(payload.iss) : "";
|
|
284
|
-
if (tokenIssuer !==
|
|
447
|
+
if (tokenIssuer !== normalizedIssuer) {
|
|
285
448
|
throw new SsoError("id_token_issuer_mismatch", "ID Token issuer \u4E0D\u5339\u914D");
|
|
286
449
|
}
|
|
287
|
-
|
|
450
|
+
const aud = payload.aud;
|
|
451
|
+
const audList = Array.isArray(aud) ? aud : typeof aud === "string" ? [aud] : [];
|
|
452
|
+
if (!audList.includes(expectedClientId)) {
|
|
288
453
|
throw new SsoError("id_token_audience_mismatch", "ID Token audience \u4E0D\u5339\u914D");
|
|
289
454
|
}
|
|
290
|
-
if (
|
|
455
|
+
if (audList.length > 1 && payload.azp !== expectedClientId) {
|
|
456
|
+
throw new SsoError("id_token_audience_mismatch", "ID Token \u591A audience \u65F6 azp \u5FC5\u987B\u4E3A\u5F53\u524D client");
|
|
457
|
+
}
|
|
458
|
+
if (typeof payload.exp !== "number") {
|
|
459
|
+
throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 exp \u58F0\u660E");
|
|
460
|
+
}
|
|
461
|
+
if (Date.now() >= payload.exp * 1e3 + 6e4) {
|
|
291
462
|
throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
|
|
292
463
|
}
|
|
464
|
+
if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
|
|
465
|
+
throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
|
|
466
|
+
}
|
|
293
467
|
if (typeof payload.sub !== "string" || !payload.sub) {
|
|
294
468
|
throw new SsoError("id_token_missing_sub", "ID Token \u7F3A\u5C11 sub");
|
|
295
469
|
}
|
|
296
470
|
if (typeof payload.at_hash === "string" && payload.at_hash) {
|
|
297
471
|
const actual = await computeAtHash(accessToken);
|
|
298
|
-
if (actual
|
|
472
|
+
if (!timingSafeEqualString(actual, payload.at_hash)) {
|
|
299
473
|
throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
|
|
300
474
|
}
|
|
301
475
|
}
|
|
302
476
|
return { sub: payload.sub };
|
|
303
477
|
}
|
|
478
|
+
|
|
479
|
+
// src/core/SsoClient.ts
|
|
304
480
|
var _SsoClient = class _SsoClient {
|
|
305
481
|
constructor(config) {
|
|
306
482
|
this._discovery = null;
|
|
@@ -369,6 +545,17 @@ var _SsoClient = class _SsoClient {
|
|
|
369
545
|
if (d) return d.userinfo_endpoint;
|
|
370
546
|
return `${this.config.ssoBaseUrl}/api/oauth/userinfo`;
|
|
371
547
|
}
|
|
548
|
+
/**
|
|
549
|
+
* 开放重定向防护:仅保存相对路径或与当前页面同源的 returnUrl,
|
|
550
|
+
* 其余(如 https://evil.com)忽略并告警,防止回调后跳转到钓鱼站点。
|
|
551
|
+
*/
|
|
552
|
+
_saveReturnUrlIfTrusted(returnUrl) {
|
|
553
|
+
if (isTrustedReturnUrl(returnUrl, window.location.origin)) {
|
|
554
|
+
saveReturnUrl(returnUrl, this.config.clientId);
|
|
555
|
+
} else {
|
|
556
|
+
console.warn(`[SSO SDK] returnUrl \u672A\u901A\u8FC7\u540C\u6E90\u6821\u9A8C\uFF0C\u5DF2\u5FFD\u7565: ${returnUrl}`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
372
559
|
// ============================================
|
|
373
560
|
// 公共 API
|
|
374
561
|
// ============================================
|
|
@@ -378,7 +565,12 @@ var _SsoClient = class _SsoClient {
|
|
|
378
565
|
* 生成 PKCE code_verifier/code_challenge 和 state 参数,
|
|
379
566
|
* 构建 authorize URL,通过 302 跳转到 SSO 登录页。
|
|
380
567
|
*
|
|
381
|
-
* @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage
|
|
568
|
+
* @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
|
|
569
|
+
* 仅允许相对路径或同源绝对 URL,否则忽略并告警)
|
|
570
|
+
*
|
|
571
|
+
* ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
|
|
572
|
+
* state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
|
|
573
|
+
* 同一次登录只使用其中一个入口。
|
|
382
574
|
*/
|
|
383
575
|
async login(returnUrl) {
|
|
384
576
|
const verifier = generateCodeVerifier();
|
|
@@ -387,7 +579,7 @@ var _SsoClient = class _SsoClient {
|
|
|
387
579
|
savePkceVerifier(this.config.clientId, verifier);
|
|
388
580
|
saveOAuthState(state, this.config.clientId);
|
|
389
581
|
if (returnUrl) {
|
|
390
|
-
|
|
582
|
+
this._saveReturnUrlIfTrusted(returnUrl);
|
|
391
583
|
}
|
|
392
584
|
const authorizeEndpoint = await this._getAuthorizeEndpoint();
|
|
393
585
|
const params = new URLSearchParams();
|
|
@@ -404,6 +596,10 @@ var _SsoClient = class _SsoClient {
|
|
|
404
596
|
* 构建登录 URL(不跳转,返回 URL 字符串)
|
|
405
597
|
*
|
|
406
598
|
* 适用于需要手动处理跳转的场景。
|
|
599
|
+
*
|
|
600
|
+
* ⚠️ 不要与 login() 混用:两者都会重新生成并覆盖 sessionStorage 中的
|
|
601
|
+
* state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
|
|
602
|
+
* 同一次登录只使用其中一个入口。
|
|
407
603
|
*/
|
|
408
604
|
async getLoginUrl(returnUrl) {
|
|
409
605
|
const verifier = generateCodeVerifier();
|
|
@@ -411,7 +607,7 @@ var _SsoClient = class _SsoClient {
|
|
|
411
607
|
const state = generateState();
|
|
412
608
|
savePkceVerifier(this.config.clientId, verifier);
|
|
413
609
|
saveOAuthState(state, this.config.clientId);
|
|
414
|
-
if (returnUrl)
|
|
610
|
+
if (returnUrl) this._saveReturnUrlIfTrusted(returnUrl);
|
|
415
611
|
const authorizeEndpoint = await this._getAuthorizeEndpoint();
|
|
416
612
|
const params = new URLSearchParams();
|
|
417
613
|
params.set("response_type", "code");
|
|
@@ -423,11 +619,140 @@ var _SsoClient = class _SsoClient {
|
|
|
423
619
|
params.set("code_challenge_method", "S256");
|
|
424
620
|
return `${authorizeEndpoint}?${params.toString()}`;
|
|
425
621
|
}
|
|
622
|
+
/**
|
|
623
|
+
* 弹窗模式 SSO 登录
|
|
624
|
+
*
|
|
625
|
+
* 打开一个小窗口进行登录,认证完成后窗口自动关闭,
|
|
626
|
+
* 主页面不丢失状态(适用于 SPA 中需要保持表单/浏览上下文的场景)。
|
|
627
|
+
*
|
|
628
|
+
* 流程:
|
|
629
|
+
* 1. window.open() 打开授权 URL 到弹窗
|
|
630
|
+
* 2. 用户在弹窗中完成登录
|
|
631
|
+
* 3. 弹窗加载 CallbackPage 时检测到 window.opener,通过 postMessage 回传回调 URL
|
|
632
|
+
* 4. 主页面收到消息后调用 handleCallback() 交换 token
|
|
633
|
+
* 5. 弹窗自动关闭
|
|
634
|
+
*
|
|
635
|
+
* @param options - 弹窗配置
|
|
636
|
+
* @param options.returnUrl - 登录成功后的返回地址
|
|
637
|
+
* @param options.width - 弹窗宽度(默认 480)
|
|
638
|
+
* @param options.height - 弹窗高度(默认 640)
|
|
639
|
+
* @returns TokenData
|
|
640
|
+
*
|
|
641
|
+
* @example
|
|
642
|
+
* ```typescript
|
|
643
|
+
* const client = new SsoClient({ clientId: "xxx", redirectUri: "https://myapp.com/callback", ssoBaseUrl: "https://nihplod.cn" });
|
|
644
|
+
* try {
|
|
645
|
+
* const tokenData = await client.loginPopup({ returnUrl: "/dashboard" });
|
|
646
|
+
* console.log("登录成功", tokenData);
|
|
647
|
+
* } catch (err) {
|
|
648
|
+
* if (err instanceof SsoError && err.code === "popup_blocked") {
|
|
649
|
+
* // 弹窗被拦截,回退到同页重定向
|
|
650
|
+
* await client.login("/dashboard");
|
|
651
|
+
* }
|
|
652
|
+
* }
|
|
653
|
+
* ```
|
|
654
|
+
*/
|
|
655
|
+
async loginPopup(options = {}) {
|
|
656
|
+
const verifier = generateCodeVerifier();
|
|
657
|
+
const challenge = await generateCodeChallenge(verifier);
|
|
658
|
+
const state = generateState();
|
|
659
|
+
const popupNonce = generateState();
|
|
660
|
+
savePkceVerifier(this.config.clientId, verifier);
|
|
661
|
+
saveOAuthState(state, this.config.clientId);
|
|
662
|
+
savePkceVerifier(`${this.config.clientId}_popup_nonce`, popupNonce);
|
|
663
|
+
if (options.returnUrl) {
|
|
664
|
+
this._saveReturnUrlIfTrusted(options.returnUrl);
|
|
665
|
+
}
|
|
666
|
+
const authorizeEndpoint = await this._getAuthorizeEndpoint();
|
|
667
|
+
const params = new URLSearchParams();
|
|
668
|
+
params.set("response_type", "code");
|
|
669
|
+
params.set("client_id", this.config.clientId);
|
|
670
|
+
params.set("redirect_uri", this.config.redirectUri);
|
|
671
|
+
params.set("scope", this.config.scopes || "openid profile");
|
|
672
|
+
params.set("state", state);
|
|
673
|
+
params.set("code_challenge", challenge);
|
|
674
|
+
params.set("code_challenge_method", "S256");
|
|
675
|
+
params.set("popup_nonce", popupNonce);
|
|
676
|
+
const width = options.width || 480;
|
|
677
|
+
const height = options.height || 640;
|
|
678
|
+
const left = Math.max(0, (window.screen.width - width) / 2);
|
|
679
|
+
const top = Math.max(0, (window.screen.height - height) / 2);
|
|
680
|
+
const popupFeatures = [
|
|
681
|
+
`width=${width}`,
|
|
682
|
+
`height=${height}`,
|
|
683
|
+
`left=${Math.round(left)}`,
|
|
684
|
+
`top=${Math.round(top)}`,
|
|
685
|
+
"resizable=yes",
|
|
686
|
+
"scrollbars=yes",
|
|
687
|
+
"status=yes"
|
|
688
|
+
].join(",");
|
|
689
|
+
const popup = window.open(
|
|
690
|
+
`${authorizeEndpoint}?${params.toString()}`,
|
|
691
|
+
"nihplod_sso_popup",
|
|
692
|
+
popupFeatures
|
|
693
|
+
);
|
|
694
|
+
if (!popup) {
|
|
695
|
+
removePkceVerifier(this.config.clientId);
|
|
696
|
+
removeOAuthState(this.config.clientId);
|
|
697
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
698
|
+
throw new SsoError(
|
|
699
|
+
"popup_blocked",
|
|
700
|
+
"\u5F39\u7A97\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5"
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
const redirectUriOrigin = new URL(this.config.redirectUri).origin;
|
|
704
|
+
try {
|
|
705
|
+
popup.focus();
|
|
706
|
+
return await new Promise((resolve, reject) => {
|
|
707
|
+
let completed = false;
|
|
708
|
+
const handleMessage = (event) => {
|
|
709
|
+
if (completed) return;
|
|
710
|
+
if (event.origin !== redirectUriOrigin) return;
|
|
711
|
+
if (!event.data || event.data.type !== "nihplod_sso_popup_callback") return;
|
|
712
|
+
if (!event.data.callbackUrl) return;
|
|
713
|
+
const savedNonce = getPkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
714
|
+
if (!savedNonce || !timingSafeEqualString(event.data.nonce ?? "", savedNonce)) return;
|
|
715
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
716
|
+
completed = true;
|
|
717
|
+
cleanup();
|
|
718
|
+
if (popup && !popup.closed) {
|
|
719
|
+
popup.close();
|
|
720
|
+
}
|
|
721
|
+
this.handleCallback(event.data.callbackUrl).then(resolve).catch(reject);
|
|
722
|
+
};
|
|
723
|
+
const pollTimer = setInterval(() => {
|
|
724
|
+
if (popup.closed) {
|
|
725
|
+
if (!completed) {
|
|
726
|
+
completed = true;
|
|
727
|
+
cleanup();
|
|
728
|
+
reject(
|
|
729
|
+
new SsoError(
|
|
730
|
+
"popup_closed",
|
|
731
|
+
"\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED"
|
|
732
|
+
)
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}, 500);
|
|
737
|
+
const cleanup = () => {
|
|
738
|
+
clearInterval(pollTimer);
|
|
739
|
+
window.removeEventListener("message", handleMessage);
|
|
740
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
741
|
+
};
|
|
742
|
+
window.addEventListener("message", handleMessage);
|
|
743
|
+
});
|
|
744
|
+
} catch (err) {
|
|
745
|
+
removePkceVerifier(this.config.clientId);
|
|
746
|
+
removeOAuthState(this.config.clientId);
|
|
747
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
748
|
+
throw err;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
426
751
|
/**
|
|
427
752
|
* 处理 OAuth 回调
|
|
428
753
|
*
|
|
429
754
|
* 解析回调 URL,校验 state 参数,用授权码交换 token。
|
|
430
|
-
* 成功后 token 自动保存到 sessionStorage
|
|
755
|
+
* 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
|
|
431
756
|
*
|
|
432
757
|
* @param callbackUrl - 完整的回调 URL(window.location.href)
|
|
433
758
|
* @returns TokenData 或 null
|
|
@@ -437,23 +762,27 @@ var _SsoClient = class _SsoClient {
|
|
|
437
762
|
const params = url.searchParams;
|
|
438
763
|
const error = params.get("error");
|
|
439
764
|
if (error) {
|
|
765
|
+
removeOAuthState(this.config.clientId);
|
|
766
|
+
removePkceVerifier(this.config.clientId);
|
|
440
767
|
const desc = params.get("error_description") || error;
|
|
441
|
-
throw new SsoError(
|
|
768
|
+
throw new SsoError(mapOAuthErrorToSsoCode(error), `\u6388\u6743\u5931\u8D25: ${desc}`);
|
|
442
769
|
}
|
|
443
770
|
const code = params.get("code");
|
|
444
771
|
const returnedState = params.get("state");
|
|
445
772
|
if (!code) {
|
|
773
|
+
removeOAuthState(this.config.clientId);
|
|
774
|
+
removePkceVerifier(this.config.clientId);
|
|
446
775
|
throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
|
|
447
776
|
}
|
|
448
777
|
const savedState = getOAuthState(this.config.clientId);
|
|
449
|
-
if (!savedState || savedState
|
|
778
|
+
if (!savedState || !timingSafeEqualString(savedState, returnedState ?? "")) {
|
|
450
779
|
removeOAuthState(this.config.clientId);
|
|
780
|
+
removePkceVerifier(this.config.clientId);
|
|
451
781
|
throw new SsoError(
|
|
452
782
|
"state_mismatch",
|
|
453
783
|
"State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
|
|
454
784
|
);
|
|
455
785
|
}
|
|
456
|
-
removeOAuthState(this.config.clientId);
|
|
457
786
|
const verifier = getPkceVerifier(this.config.clientId);
|
|
458
787
|
if (!verifier) {
|
|
459
788
|
throw new SsoError(
|
|
@@ -461,7 +790,6 @@ var _SsoClient = class _SsoClient {
|
|
|
461
790
|
"code_verifier \u4E0D\u5B58\u5728\uFF08\u53EF\u80FD\u5DF2\u8FC7\u671F\u6216\u6765\u81EA\u5176\u4ED6\u6807\u7B7E\u9875\uFF09"
|
|
462
791
|
);
|
|
463
792
|
}
|
|
464
|
-
removePkceVerifier(this.config.clientId);
|
|
465
793
|
const tokenEndpoint = await this._getTokenEndpoint();
|
|
466
794
|
const body = new URLSearchParams();
|
|
467
795
|
body.set("grant_type", "authorization_code");
|
|
@@ -488,8 +816,9 @@ var _SsoClient = class _SsoClient {
|
|
|
488
816
|
errData = await res.json();
|
|
489
817
|
} catch {
|
|
490
818
|
}
|
|
819
|
+
const serverError = errData.error || "";
|
|
491
820
|
throw new SsoError(
|
|
492
|
-
|
|
821
|
+
mapOAuthErrorToSsoCode(serverError),
|
|
493
822
|
errData.error_description || `Token \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}`
|
|
494
823
|
);
|
|
495
824
|
}
|
|
@@ -498,15 +827,17 @@ var _SsoClient = class _SsoClient {
|
|
|
498
827
|
try {
|
|
499
828
|
await validateIdToken(
|
|
500
829
|
data.id_token,
|
|
830
|
+
data.access_token,
|
|
501
831
|
this.config.ssoBaseUrl,
|
|
502
|
-
this.config.clientId
|
|
503
|
-
data.access_token
|
|
832
|
+
this.config.clientId
|
|
504
833
|
);
|
|
505
834
|
} catch (err) {
|
|
506
835
|
removeTokenData(this.config.clientId);
|
|
507
836
|
throw err;
|
|
508
837
|
}
|
|
509
838
|
}
|
|
839
|
+
removeOAuthState(this.config.clientId);
|
|
840
|
+
removePkceVerifier(this.config.clientId);
|
|
510
841
|
const now = Date.now();
|
|
511
842
|
const tokenData = {
|
|
512
843
|
access_token: data.access_token,
|
|
@@ -549,15 +880,24 @@ var _SsoClient = class _SsoClient {
|
|
|
549
880
|
if (this.config.clientSecret) {
|
|
550
881
|
body.set("client_secret", this.config.clientSecret);
|
|
551
882
|
}
|
|
552
|
-
let res;
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
883
|
+
let res = null;
|
|
884
|
+
let lastErr;
|
|
885
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
886
|
+
try {
|
|
887
|
+
res = await fetch(tokenEndpoint, {
|
|
888
|
+
method: "POST",
|
|
889
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
890
|
+
body: body.toString()
|
|
891
|
+
});
|
|
892
|
+
lastErr = null;
|
|
893
|
+
break;
|
|
894
|
+
} catch (err) {
|
|
895
|
+
lastErr = err;
|
|
896
|
+
if (attempt < 1) await new Promise((r) => setTimeout(r, 1e3));
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
if (lastErr || !res) {
|
|
900
|
+
throw new SsoError("network_error", "\u5237\u65B0 Token \u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", lastErr);
|
|
561
901
|
}
|
|
562
902
|
if (!res.ok) {
|
|
563
903
|
let errData = {};
|
|
@@ -570,17 +910,31 @@ var _SsoClient = class _SsoClient {
|
|
|
570
910
|
removeTokenData(this.config.clientId);
|
|
571
911
|
}
|
|
572
912
|
throw new SsoError(
|
|
573
|
-
errorCode
|
|
913
|
+
mapOAuthErrorToSsoCode(errorCode, "refresh"),
|
|
574
914
|
errData.error_description || `\u5237\u65B0 Token \u5931\u8D25: HTTP ${res.status}`
|
|
575
915
|
);
|
|
576
916
|
}
|
|
577
917
|
const data = await res.json();
|
|
918
|
+
if (data.id_token) {
|
|
919
|
+
try {
|
|
920
|
+
await validateIdToken(
|
|
921
|
+
data.id_token,
|
|
922
|
+
data.access_token,
|
|
923
|
+
this.config.ssoBaseUrl,
|
|
924
|
+
this.config.clientId
|
|
925
|
+
);
|
|
926
|
+
} catch (err) {
|
|
927
|
+
removeTokenData(this.config.clientId);
|
|
928
|
+
throw err;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
578
931
|
const now = Date.now();
|
|
579
932
|
const tokenData = {
|
|
580
933
|
access_token: data.access_token,
|
|
581
934
|
token_type: data.token_type,
|
|
582
935
|
expires_in: data.expires_in,
|
|
583
|
-
|
|
936
|
+
// RFC 6749 §6:刷新响应可省略 refresh_token,此时沿用旧值
|
|
937
|
+
refresh_token: data.refresh_token || current.refresh_token,
|
|
584
938
|
id_token: data.id_token,
|
|
585
939
|
issued_at: now,
|
|
586
940
|
expires_at: now + data.expires_in * 1e3
|
|
@@ -652,7 +1006,9 @@ var _SsoClient = class _SsoClient {
|
|
|
652
1006
|
* 登出
|
|
653
1007
|
*
|
|
654
1008
|
* 清除本地所有 token 和临时数据,并尝试撤销服务端 refresh_token。
|
|
655
|
-
* @param redirectToSso - 是否重定向到 SSO 登出页(默认 false
|
|
1009
|
+
* @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)。
|
|
1010
|
+
* 为 true 时携带 state 参数(已保存到 sessionStorage),
|
|
1011
|
+
* 回跳页面应调用 validateLogoutState() 校验防登出 CSRF。
|
|
656
1012
|
*/
|
|
657
1013
|
async logout(redirectToSso = false) {
|
|
658
1014
|
const tokenData = getTokenData(this.config.clientId);
|
|
@@ -681,23 +1037,50 @@ var _SsoClient = class _SsoClient {
|
|
|
681
1037
|
}
|
|
682
1038
|
if (redirectToSso) {
|
|
683
1039
|
const discovery = await this._getDiscovery();
|
|
684
|
-
const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/
|
|
1040
|
+
const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/api/oauth/end-session`;
|
|
685
1041
|
const logoutUrl = new URL(endSessionEndpoint);
|
|
686
1042
|
logoutUrl.searchParams.set("client_id", this.config.clientId);
|
|
687
|
-
|
|
1043
|
+
const postLogoutUri = this.config.postLogoutRedirectUri || this.config.redirectUri;
|
|
1044
|
+
if (postLogoutUri) {
|
|
688
1045
|
logoutUrl.searchParams.set(
|
|
689
1046
|
"post_logout_redirect_uri",
|
|
690
|
-
|
|
1047
|
+
postLogoutUri
|
|
691
1048
|
);
|
|
692
1049
|
}
|
|
693
1050
|
if (idTokenHint) {
|
|
694
1051
|
logoutUrl.searchParams.set("id_token_hint", idTokenHint);
|
|
695
1052
|
}
|
|
696
1053
|
const state = generateState();
|
|
1054
|
+
saveLogoutState(state, this.config.clientId);
|
|
697
1055
|
logoutUrl.searchParams.set("state", state);
|
|
698
1056
|
window.location.href = logoutUrl.toString();
|
|
699
1057
|
}
|
|
700
1058
|
}
|
|
1059
|
+
/**
|
|
1060
|
+
* 校验 RP-Initiated Logout 回跳的 state 参数(登出 CSRF 防护)
|
|
1061
|
+
*
|
|
1062
|
+
* 在 post_logout_redirect_uri 指向的页面加载时调用;
|
|
1063
|
+
* 仅在 URL 携带 state 且与 logout(redirectToSso=true) 保存的值一致时返回 true,
|
|
1064
|
+
* 校验后清除已保存的 logout state(一次性)。
|
|
1065
|
+
*
|
|
1066
|
+
* @param url - 当前页面完整 URL(window.location.href)
|
|
1067
|
+
*
|
|
1068
|
+
* @example
|
|
1069
|
+
* ```typescript
|
|
1070
|
+
* if (sso.validateLogoutState(window.location.href)) {
|
|
1071
|
+
* // 来自 SSO 登出的可信回跳
|
|
1072
|
+
* }
|
|
1073
|
+
* ```
|
|
1074
|
+
*/
|
|
1075
|
+
validateLogoutState(url) {
|
|
1076
|
+
const returnedState = new URL(url).searchParams.get("state");
|
|
1077
|
+
if (!returnedState) return false;
|
|
1078
|
+
const savedState = getLogoutState(this.config.clientId);
|
|
1079
|
+
if (!savedState) return false;
|
|
1080
|
+
if (!timingSafeEqualString(savedState, returnedState)) return false;
|
|
1081
|
+
removeLogoutState(this.config.clientId);
|
|
1082
|
+
return true;
|
|
1083
|
+
}
|
|
701
1084
|
/**
|
|
702
1085
|
* 获取 OIDC Discovery 文档
|
|
703
1086
|
*
|
|
@@ -719,22 +1102,29 @@ export {
|
|
|
719
1102
|
SsoError,
|
|
720
1103
|
clearAllSsoData,
|
|
721
1104
|
clearVerifiersForClients,
|
|
1105
|
+
createSecureStorage,
|
|
722
1106
|
generateCodeChallenge,
|
|
723
1107
|
generateCodeVerifier,
|
|
724
1108
|
generateState,
|
|
1109
|
+
getLogoutState,
|
|
725
1110
|
getOAuthState,
|
|
726
1111
|
getPkceVerifier,
|
|
727
1112
|
getReturnUrl,
|
|
728
1113
|
getTokenData,
|
|
729
1114
|
getTokenStorage,
|
|
1115
|
+
isTrustedReturnUrl,
|
|
1116
|
+
mapOAuthErrorToSsoCode,
|
|
1117
|
+
removeLogoutState,
|
|
730
1118
|
removeOAuthState,
|
|
731
1119
|
removePkceVerifier,
|
|
732
1120
|
removeReturnUrl,
|
|
733
1121
|
removeTokenData,
|
|
1122
|
+
saveLogoutState,
|
|
734
1123
|
saveOAuthState,
|
|
735
1124
|
savePkceVerifier,
|
|
736
1125
|
saveReturnUrl,
|
|
737
1126
|
saveTokenData,
|
|
738
|
-
setTokenStorage
|
|
1127
|
+
setTokenStorage,
|
|
1128
|
+
timingSafeEqualString
|
|
739
1129
|
};
|
|
740
1130
|
//# sourceMappingURL=index.mjs.map
|