@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/react/index.mjs
CHANGED
|
@@ -9,6 +9,26 @@ import React, {
|
|
|
9
9
|
} from "react";
|
|
10
10
|
|
|
11
11
|
// src/core/errors.ts
|
|
12
|
+
function mapOAuthErrorToSsoCode(oauthError, context = "token_exchange") {
|
|
13
|
+
switch (oauthError) {
|
|
14
|
+
case "invalid_grant":
|
|
15
|
+
return context === "refresh" ? "session_expired" : "authorization_code_expired";
|
|
16
|
+
case "invalid_client":
|
|
17
|
+
return "client_disabled";
|
|
18
|
+
case "access_denied":
|
|
19
|
+
return "user_denied_authorization";
|
|
20
|
+
case "server_error":
|
|
21
|
+
return "sso_server_error";
|
|
22
|
+
case "rate_limited":
|
|
23
|
+
return "rate_limited";
|
|
24
|
+
case "invalid_scope":
|
|
25
|
+
case "invalid_request":
|
|
26
|
+
case "unauthorized_client":
|
|
27
|
+
case "unsupported_grant_type":
|
|
28
|
+
default:
|
|
29
|
+
return "token_request_failed";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
12
32
|
var SsoError = class extends Error {
|
|
13
33
|
constructor(code, description, cause) {
|
|
14
34
|
super(`[SSO SDK] ${code}: ${description}`);
|
|
@@ -64,6 +84,7 @@ var TOKEN_KEY = "token";
|
|
|
64
84
|
var VERIFIER_KEY_PREFIX = "pkce_verifier_";
|
|
65
85
|
var STATE_KEY = "oauth_state";
|
|
66
86
|
var RETURN_URL_KEY = "return_url";
|
|
87
|
+
var LOGOUT_STATE_KEY = "logout_state";
|
|
67
88
|
function buildKey(base, clientId) {
|
|
68
89
|
return clientId ? `${base}:${clientId}` : base;
|
|
69
90
|
}
|
|
@@ -82,7 +103,35 @@ function createMemoryStorageAdapter() {
|
|
|
82
103
|
};
|
|
83
104
|
}
|
|
84
105
|
var memoryStorageAdapter = createMemoryStorageAdapter();
|
|
85
|
-
|
|
106
|
+
function createSessionStorageAdapter() {
|
|
107
|
+
const fallback = /* @__PURE__ */ new Map();
|
|
108
|
+
return {
|
|
109
|
+
get(key) {
|
|
110
|
+
if (typeof sessionStorage !== "undefined") {
|
|
111
|
+
return sessionStorage.getItem(STORAGE_PREFIX + key) ?? fallback.get(key) ?? null;
|
|
112
|
+
}
|
|
113
|
+
return fallback.get(key) ?? null;
|
|
114
|
+
},
|
|
115
|
+
set(key, value) {
|
|
116
|
+
if (typeof sessionStorage === "undefined") {
|
|
117
|
+
fallback.set(key, value);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
sessionStorage.setItem(STORAGE_PREFIX + key, value);
|
|
122
|
+
} catch {
|
|
123
|
+
fallback.set(key, value);
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
remove(key) {
|
|
127
|
+
fallback.delete(key);
|
|
128
|
+
if (typeof sessionStorage === "undefined") return;
|
|
129
|
+
sessionStorage.removeItem(STORAGE_PREFIX + key);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
var _transient = createSessionStorageAdapter();
|
|
134
|
+
var _storage = createSessionStorageAdapter();
|
|
86
135
|
function saveTokenData(data, clientId) {
|
|
87
136
|
_storage.set(buildKey(TOKEN_KEY, clientId), JSON.stringify(data));
|
|
88
137
|
}
|
|
@@ -99,55 +148,102 @@ function removeTokenData(clientId) {
|
|
|
99
148
|
_storage.remove(buildKey(TOKEN_KEY, clientId));
|
|
100
149
|
}
|
|
101
150
|
function savePkceVerifier(clientId, verifier) {
|
|
102
|
-
|
|
151
|
+
_transient.set(VERIFIER_KEY_PREFIX + clientId, verifier);
|
|
103
152
|
}
|
|
104
153
|
function getPkceVerifier(clientId) {
|
|
105
|
-
return
|
|
154
|
+
return _transient.get(VERIFIER_KEY_PREFIX + clientId);
|
|
106
155
|
}
|
|
107
156
|
function removePkceVerifier(clientId) {
|
|
108
|
-
|
|
157
|
+
_transient.remove(VERIFIER_KEY_PREFIX + clientId);
|
|
109
158
|
}
|
|
110
159
|
function saveOAuthState(state, clientId) {
|
|
111
|
-
|
|
160
|
+
_transient.set(buildKey(STATE_KEY, clientId), state);
|
|
112
161
|
}
|
|
113
162
|
function getOAuthState(clientId) {
|
|
114
|
-
return
|
|
163
|
+
return _transient.get(buildKey(STATE_KEY, clientId));
|
|
115
164
|
}
|
|
116
165
|
function removeOAuthState(clientId) {
|
|
117
|
-
|
|
166
|
+
_transient.remove(buildKey(STATE_KEY, clientId));
|
|
167
|
+
}
|
|
168
|
+
function saveLogoutState(state, clientId) {
|
|
169
|
+
_transient.set(buildKey(LOGOUT_STATE_KEY, clientId), state);
|
|
170
|
+
}
|
|
171
|
+
function getLogoutState(clientId) {
|
|
172
|
+
return _transient.get(buildKey(LOGOUT_STATE_KEY, clientId));
|
|
173
|
+
}
|
|
174
|
+
function removeLogoutState(clientId) {
|
|
175
|
+
_transient.remove(buildKey(LOGOUT_STATE_KEY, clientId));
|
|
118
176
|
}
|
|
119
177
|
function saveReturnUrl(url, clientId) {
|
|
120
|
-
|
|
178
|
+
_transient.set(buildKey(RETURN_URL_KEY, clientId), url);
|
|
121
179
|
}
|
|
122
180
|
function getReturnUrl(clientId) {
|
|
123
|
-
return
|
|
181
|
+
return _transient.get(buildKey(RETURN_URL_KEY, clientId));
|
|
124
182
|
}
|
|
125
183
|
function removeReturnUrl(clientId) {
|
|
126
|
-
|
|
184
|
+
_transient.remove(buildKey(RETURN_URL_KEY, clientId));
|
|
127
185
|
}
|
|
128
186
|
function clearAllSsoData(clientId) {
|
|
129
187
|
if (clientId) {
|
|
130
188
|
removeTokenData(clientId);
|
|
131
189
|
removeOAuthState(clientId);
|
|
132
190
|
removeReturnUrl(clientId);
|
|
191
|
+
removeLogoutState(clientId);
|
|
133
192
|
removePkceVerifier(clientId);
|
|
193
|
+
removePkceVerifier(`${clientId}_popup_nonce`);
|
|
134
194
|
return;
|
|
135
195
|
}
|
|
136
196
|
removeTokenData();
|
|
137
197
|
removeOAuthState();
|
|
138
198
|
removeReturnUrl();
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
199
|
+
removeLogoutState();
|
|
200
|
+
const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
|
|
201
|
+
const stores = [
|
|
202
|
+
typeof sessionStorage !== "undefined" ? sessionStorage : null,
|
|
203
|
+
typeof localStorage !== "undefined" ? localStorage : null
|
|
204
|
+
];
|
|
205
|
+
for (const store of stores) {
|
|
206
|
+
if (!store) continue;
|
|
207
|
+
const keys = [];
|
|
208
|
+
for (let i = 0; i < store.length; i++) {
|
|
209
|
+
const key = store.key(i);
|
|
210
|
+
if (key?.startsWith(prefix)) keys.push(key);
|
|
211
|
+
}
|
|
212
|
+
for (const key of keys) {
|
|
213
|
+
_transient.remove(key.slice(STORAGE_PREFIX.length));
|
|
214
|
+
store.removeItem(key);
|
|
146
215
|
}
|
|
147
216
|
}
|
|
148
217
|
}
|
|
149
218
|
|
|
150
|
-
// src/core/
|
|
219
|
+
// src/core/security.ts
|
|
220
|
+
function isTrustedReturnUrl(url, currentOrigin) {
|
|
221
|
+
if (!url) return false;
|
|
222
|
+
if (url.includes("\\")) return false;
|
|
223
|
+
if (url.startsWith("/") && !url.startsWith("//")) return true;
|
|
224
|
+
try {
|
|
225
|
+
return new URL(url).origin === currentOrigin;
|
|
226
|
+
} catch {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function timingSafeEqualString(a, b) {
|
|
231
|
+
const ba = new TextEncoder().encode(a);
|
|
232
|
+
const bb = new TextEncoder().encode(b);
|
|
233
|
+
if (ba.length === 0 || bb.length === 0) return ba.length === bb.length;
|
|
234
|
+
const len = Math.max(ba.length, bb.length);
|
|
235
|
+
let diff = ba.length ^ bb.length;
|
|
236
|
+
for (let i = 0; i < len; i++) {
|
|
237
|
+
diff |= ba[i % ba.length] ^ bb[i % bb.length];
|
|
238
|
+
}
|
|
239
|
+
return diff === 0;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// src/core/id-token.ts
|
|
243
|
+
var _crypto = typeof crypto !== "undefined" && crypto.subtle ? crypto : null;
|
|
244
|
+
function getCrypto() {
|
|
245
|
+
return _crypto ?? null;
|
|
246
|
+
}
|
|
151
247
|
function base64UrlDecode(input) {
|
|
152
248
|
const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
153
249
|
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
|
|
@@ -182,14 +278,32 @@ function normalizeIssuer(url) {
|
|
|
182
278
|
return url.replace(/\/+$/, "");
|
|
183
279
|
}
|
|
184
280
|
var cachedJwks = null;
|
|
281
|
+
var cachedDiscovery = null;
|
|
185
282
|
var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
186
|
-
async function
|
|
283
|
+
async function fetchDiscoveryDoc(baseUrl) {
|
|
187
284
|
const now = Date.now();
|
|
188
|
-
if (
|
|
285
|
+
if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
|
|
286
|
+
return cachedDiscovery.doc;
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`);
|
|
290
|
+
const doc = res.ok ? await res.json() : null;
|
|
291
|
+
cachedDiscovery = { baseUrl, doc, fetchedAt: now };
|
|
292
|
+
return doc;
|
|
293
|
+
} catch {
|
|
294
|
+
cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
async function fetchJwks(baseUrl, options = {}) {
|
|
299
|
+
const now = Date.now();
|
|
300
|
+
if (!options.forceRefresh && cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
|
|
189
301
|
return cachedJwks.jwks;
|
|
190
302
|
}
|
|
303
|
+
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
304
|
+
const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
|
|
191
305
|
try {
|
|
192
|
-
const res = await fetch(
|
|
306
|
+
const res = await fetch(jwksUri, options.forceRefresh ? { cache: "no-cache" } : void 0);
|
|
193
307
|
if (!res.ok) return null;
|
|
194
308
|
const jwks = await res.json();
|
|
195
309
|
cachedJwks = { baseUrl, jwks, fetchedAt: now };
|
|
@@ -198,24 +312,13 @@ async function fetchJwks(baseUrl) {
|
|
|
198
312
|
return null;
|
|
199
313
|
}
|
|
200
314
|
}
|
|
201
|
-
async function
|
|
202
|
-
const hash = await crypto.subtle.digest(
|
|
203
|
-
"SHA-256",
|
|
204
|
-
new TextEncoder().encode(accessToken)
|
|
205
|
-
);
|
|
206
|
-
const bytes = new Uint8Array(hash);
|
|
207
|
-
const half = bytes.slice(0, bytes.length / 2);
|
|
208
|
-
let binary = "";
|
|
209
|
-
for (const b of half) {
|
|
210
|
-
binary += String.fromCharCode(b);
|
|
211
|
-
}
|
|
212
|
-
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
213
|
-
}
|
|
214
|
-
async function verifyRs256(token, jwk) {
|
|
315
|
+
async function verifyRs256Signature(token, jwk) {
|
|
215
316
|
try {
|
|
216
317
|
const [headerB64, payloadB64, signature] = token.split(".");
|
|
217
318
|
if (!signature || !jwk.n || !jwk.e) return false;
|
|
218
|
-
const
|
|
319
|
+
const c = getCrypto();
|
|
320
|
+
if (!c) return false;
|
|
321
|
+
const cryptoKey = await c.subtle.importKey(
|
|
219
322
|
"jwk",
|
|
220
323
|
{ kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: false },
|
|
221
324
|
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
@@ -224,7 +327,7 @@ async function verifyRs256(token, jwk) {
|
|
|
224
327
|
);
|
|
225
328
|
const signatureBytes = base64UrlDecode(signature);
|
|
226
329
|
const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
|
|
227
|
-
return await
|
|
330
|
+
return await c.subtle.verify(
|
|
228
331
|
"RSASSA-PKCS1-v1_5",
|
|
229
332
|
cryptoKey,
|
|
230
333
|
signatureBytes,
|
|
@@ -234,7 +337,20 @@ async function verifyRs256(token, jwk) {
|
|
|
234
337
|
return false;
|
|
235
338
|
}
|
|
236
339
|
}
|
|
237
|
-
async function
|
|
340
|
+
async function computeAtHash(accessToken) {
|
|
341
|
+
const c = getCrypto();
|
|
342
|
+
if (!c) return "";
|
|
343
|
+
const hash = await c.subtle.digest("SHA-256", new TextEncoder().encode(accessToken));
|
|
344
|
+
const bytes = new Uint8Array(hash);
|
|
345
|
+
const half = bytes.slice(0, bytes.length / 2);
|
|
346
|
+
let binary = "";
|
|
347
|
+
for (const b of half) {
|
|
348
|
+
binary += String.fromCharCode(b);
|
|
349
|
+
}
|
|
350
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
351
|
+
}
|
|
352
|
+
async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
|
|
353
|
+
const { rejectHs256WhenRs256Available = true } = options;
|
|
238
354
|
const header = decodeJwtHeader(idToken);
|
|
239
355
|
if (!header) {
|
|
240
356
|
throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
|
|
@@ -243,54 +359,91 @@ async function validateIdToken(idToken, expectedIssuer, expectedClientId, access
|
|
|
243
359
|
if (typeof alg !== "string" || alg !== "RS256" && alg !== "HS256") {
|
|
244
360
|
throw new SsoError("id_token_unsupported_alg", `\u4E0D\u652F\u6301\u7684 ID Token \u7B7E\u540D\u7B97\u6CD5: ${alg}`);
|
|
245
361
|
}
|
|
362
|
+
const baseUrl = normalizeIssuer(expectedIssuer);
|
|
363
|
+
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
364
|
+
const normalizedIssuer = normalizeIssuer(discovery?.issuer || baseUrl);
|
|
246
365
|
if (alg === "RS256") {
|
|
247
|
-
const jwks = await fetchJwks(
|
|
366
|
+
const jwks = await fetchJwks(baseUrl);
|
|
248
367
|
if (!jwks) {
|
|
249
368
|
throw new SsoError("id_token_invalid_signature", "\u65E0\u6CD5\u83B7\u53D6 JWKS \u9A8C\u8BC1 ID Token \u7B7E\u540D");
|
|
250
369
|
}
|
|
251
370
|
const kid = typeof header.kid === "string" ? header.kid : void 0;
|
|
252
|
-
const
|
|
371
|
+
const matchCandidates = (set) => set.keys.filter(
|
|
253
372
|
(k) => k.kty === "RSA" && k.alg === "RS256" && k.use === "sig" && (kid ? k.kid === kid : true)
|
|
254
373
|
);
|
|
255
|
-
|
|
374
|
+
const verifyAny = async (keys) => {
|
|
375
|
+
for (const key of keys) {
|
|
376
|
+
if (await verifyRs256Signature(idToken, key)) return true;
|
|
377
|
+
}
|
|
378
|
+
return false;
|
|
379
|
+
};
|
|
380
|
+
let candidates = matchCandidates(jwks);
|
|
381
|
+
let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
|
|
382
|
+
if (candidates.length === 0 || !validSig) {
|
|
383
|
+
const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });
|
|
384
|
+
if (freshJwks) {
|
|
385
|
+
candidates = matchCandidates(freshJwks);
|
|
386
|
+
validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (candidates.length === 0) {
|
|
256
390
|
throw new SsoError("id_token_invalid_signature", "JWKS \u4E2D\u672A\u627E\u5230\u5339\u914D\u7684 RS256 \u516C\u94A5");
|
|
257
391
|
}
|
|
258
|
-
const validSig = await verifyRs256(idToken, key);
|
|
259
392
|
if (!validSig) {
|
|
260
393
|
throw new SsoError("id_token_invalid_signature", "ID Token \u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25");
|
|
261
394
|
}
|
|
262
395
|
}
|
|
263
396
|
if (alg === "HS256") {
|
|
264
|
-
|
|
265
|
-
|
|
397
|
+
if (rejectHs256WhenRs256Available) {
|
|
398
|
+
const jwks = await fetchJwks(baseUrl);
|
|
399
|
+
const hasRs256 = jwks?.keys?.some((k) => k.alg === "RS256" && k.use === "sig");
|
|
400
|
+
if (hasRs256) {
|
|
401
|
+
throw new SsoError("id_token_unsupported_alg", "SSO \u4E2D\u5FC3\u5DF2\u914D\u7F6E RS256\uFF0C\u62D2\u7EDD HS256 ID Token");
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
throw new SsoError(
|
|
405
|
+
"id_token_hs256_unsupported",
|
|
406
|
+
"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"
|
|
266
407
|
);
|
|
267
408
|
}
|
|
268
409
|
const payload = decodeJwtPayload(idToken);
|
|
269
410
|
if (!payload) {
|
|
270
411
|
throw new SsoError("id_token_invalid", "ID Token payload \u89E3\u6790\u5931\u8D25");
|
|
271
412
|
}
|
|
272
|
-
const normalizedExpectedIssuer = normalizeIssuer(expectedIssuer);
|
|
273
413
|
const tokenIssuer = typeof payload.iss === "string" ? normalizeIssuer(payload.iss) : "";
|
|
274
|
-
if (tokenIssuer !==
|
|
414
|
+
if (tokenIssuer !== normalizedIssuer) {
|
|
275
415
|
throw new SsoError("id_token_issuer_mismatch", "ID Token issuer \u4E0D\u5339\u914D");
|
|
276
416
|
}
|
|
277
|
-
|
|
417
|
+
const aud = payload.aud;
|
|
418
|
+
const audList = Array.isArray(aud) ? aud : typeof aud === "string" ? [aud] : [];
|
|
419
|
+
if (!audList.includes(expectedClientId)) {
|
|
278
420
|
throw new SsoError("id_token_audience_mismatch", "ID Token audience \u4E0D\u5339\u914D");
|
|
279
421
|
}
|
|
280
|
-
if (
|
|
422
|
+
if (audList.length > 1 && payload.azp !== expectedClientId) {
|
|
423
|
+
throw new SsoError("id_token_audience_mismatch", "ID Token \u591A audience \u65F6 azp \u5FC5\u987B\u4E3A\u5F53\u524D client");
|
|
424
|
+
}
|
|
425
|
+
if (typeof payload.exp !== "number") {
|
|
426
|
+
throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 exp \u58F0\u660E");
|
|
427
|
+
}
|
|
428
|
+
if (Date.now() >= payload.exp * 1e3 + 6e4) {
|
|
281
429
|
throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
|
|
282
430
|
}
|
|
431
|
+
if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
|
|
432
|
+
throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
|
|
433
|
+
}
|
|
283
434
|
if (typeof payload.sub !== "string" || !payload.sub) {
|
|
284
435
|
throw new SsoError("id_token_missing_sub", "ID Token \u7F3A\u5C11 sub");
|
|
285
436
|
}
|
|
286
437
|
if (typeof payload.at_hash === "string" && payload.at_hash) {
|
|
287
438
|
const actual = await computeAtHash(accessToken);
|
|
288
|
-
if (actual
|
|
439
|
+
if (!timingSafeEqualString(actual, payload.at_hash)) {
|
|
289
440
|
throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
|
|
290
441
|
}
|
|
291
442
|
}
|
|
292
443
|
return { sub: payload.sub };
|
|
293
444
|
}
|
|
445
|
+
|
|
446
|
+
// src/core/SsoClient.ts
|
|
294
447
|
var _SsoClient = class _SsoClient {
|
|
295
448
|
constructor(config) {
|
|
296
449
|
this._discovery = null;
|
|
@@ -359,6 +512,17 @@ var _SsoClient = class _SsoClient {
|
|
|
359
512
|
if (d) return d.userinfo_endpoint;
|
|
360
513
|
return `${this.config.ssoBaseUrl}/api/oauth/userinfo`;
|
|
361
514
|
}
|
|
515
|
+
/**
|
|
516
|
+
* 开放重定向防护:仅保存相对路径或与当前页面同源的 returnUrl,
|
|
517
|
+
* 其余(如 https://evil.com)忽略并告警,防止回调后跳转到钓鱼站点。
|
|
518
|
+
*/
|
|
519
|
+
_saveReturnUrlIfTrusted(returnUrl) {
|
|
520
|
+
if (isTrustedReturnUrl(returnUrl, window.location.origin)) {
|
|
521
|
+
saveReturnUrl(returnUrl, this.config.clientId);
|
|
522
|
+
} else {
|
|
523
|
+
console.warn(`[SSO SDK] returnUrl \u672A\u901A\u8FC7\u540C\u6E90\u6821\u9A8C\uFF0C\u5DF2\u5FFD\u7565: ${returnUrl}`);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
362
526
|
// ============================================
|
|
363
527
|
// 公共 API
|
|
364
528
|
// ============================================
|
|
@@ -368,7 +532,12 @@ var _SsoClient = class _SsoClient {
|
|
|
368
532
|
* 生成 PKCE code_verifier/code_challenge 和 state 参数,
|
|
369
533
|
* 构建 authorize URL,通过 302 跳转到 SSO 登录页。
|
|
370
534
|
*
|
|
371
|
-
* @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage
|
|
535
|
+
* @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
|
|
536
|
+
* 仅允许相对路径或同源绝对 URL,否则忽略并告警)
|
|
537
|
+
*
|
|
538
|
+
* ⚠️ 不要与 getLoginUrl() 混用:两者都会重新生成并覆盖 sessionStorage 中的
|
|
539
|
+
* state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
|
|
540
|
+
* 同一次登录只使用其中一个入口。
|
|
372
541
|
*/
|
|
373
542
|
async login(returnUrl) {
|
|
374
543
|
const verifier = generateCodeVerifier();
|
|
@@ -377,7 +546,7 @@ var _SsoClient = class _SsoClient {
|
|
|
377
546
|
savePkceVerifier(this.config.clientId, verifier);
|
|
378
547
|
saveOAuthState(state, this.config.clientId);
|
|
379
548
|
if (returnUrl) {
|
|
380
|
-
|
|
549
|
+
this._saveReturnUrlIfTrusted(returnUrl);
|
|
381
550
|
}
|
|
382
551
|
const authorizeEndpoint = await this._getAuthorizeEndpoint();
|
|
383
552
|
const params = new URLSearchParams();
|
|
@@ -394,6 +563,10 @@ var _SsoClient = class _SsoClient {
|
|
|
394
563
|
* 构建登录 URL(不跳转,返回 URL 字符串)
|
|
395
564
|
*
|
|
396
565
|
* 适用于需要手动处理跳转的场景。
|
|
566
|
+
*
|
|
567
|
+
* ⚠️ 不要与 login() 混用:两者都会重新生成并覆盖 sessionStorage 中的
|
|
568
|
+
* state / PKCE verifier,先调用的那次授权流程将因 state 不匹配而失败。
|
|
569
|
+
* 同一次登录只使用其中一个入口。
|
|
397
570
|
*/
|
|
398
571
|
async getLoginUrl(returnUrl) {
|
|
399
572
|
const verifier = generateCodeVerifier();
|
|
@@ -401,7 +574,7 @@ var _SsoClient = class _SsoClient {
|
|
|
401
574
|
const state = generateState();
|
|
402
575
|
savePkceVerifier(this.config.clientId, verifier);
|
|
403
576
|
saveOAuthState(state, this.config.clientId);
|
|
404
|
-
if (returnUrl)
|
|
577
|
+
if (returnUrl) this._saveReturnUrlIfTrusted(returnUrl);
|
|
405
578
|
const authorizeEndpoint = await this._getAuthorizeEndpoint();
|
|
406
579
|
const params = new URLSearchParams();
|
|
407
580
|
params.set("response_type", "code");
|
|
@@ -413,11 +586,140 @@ var _SsoClient = class _SsoClient {
|
|
|
413
586
|
params.set("code_challenge_method", "S256");
|
|
414
587
|
return `${authorizeEndpoint}?${params.toString()}`;
|
|
415
588
|
}
|
|
589
|
+
/**
|
|
590
|
+
* 弹窗模式 SSO 登录
|
|
591
|
+
*
|
|
592
|
+
* 打开一个小窗口进行登录,认证完成后窗口自动关闭,
|
|
593
|
+
* 主页面不丢失状态(适用于 SPA 中需要保持表单/浏览上下文的场景)。
|
|
594
|
+
*
|
|
595
|
+
* 流程:
|
|
596
|
+
* 1. window.open() 打开授权 URL 到弹窗
|
|
597
|
+
* 2. 用户在弹窗中完成登录
|
|
598
|
+
* 3. 弹窗加载 CallbackPage 时检测到 window.opener,通过 postMessage 回传回调 URL
|
|
599
|
+
* 4. 主页面收到消息后调用 handleCallback() 交换 token
|
|
600
|
+
* 5. 弹窗自动关闭
|
|
601
|
+
*
|
|
602
|
+
* @param options - 弹窗配置
|
|
603
|
+
* @param options.returnUrl - 登录成功后的返回地址
|
|
604
|
+
* @param options.width - 弹窗宽度(默认 480)
|
|
605
|
+
* @param options.height - 弹窗高度(默认 640)
|
|
606
|
+
* @returns TokenData
|
|
607
|
+
*
|
|
608
|
+
* @example
|
|
609
|
+
* ```typescript
|
|
610
|
+
* const client = new SsoClient({ clientId: "xxx", redirectUri: "https://myapp.com/callback", ssoBaseUrl: "https://nihplod.cn" });
|
|
611
|
+
* try {
|
|
612
|
+
* const tokenData = await client.loginPopup({ returnUrl: "/dashboard" });
|
|
613
|
+
* console.log("登录成功", tokenData);
|
|
614
|
+
* } catch (err) {
|
|
615
|
+
* if (err instanceof SsoError && err.code === "popup_blocked") {
|
|
616
|
+
* // 弹窗被拦截,回退到同页重定向
|
|
617
|
+
* await client.login("/dashboard");
|
|
618
|
+
* }
|
|
619
|
+
* }
|
|
620
|
+
* ```
|
|
621
|
+
*/
|
|
622
|
+
async loginPopup(options = {}) {
|
|
623
|
+
const verifier = generateCodeVerifier();
|
|
624
|
+
const challenge = await generateCodeChallenge(verifier);
|
|
625
|
+
const state = generateState();
|
|
626
|
+
const popupNonce = generateState();
|
|
627
|
+
savePkceVerifier(this.config.clientId, verifier);
|
|
628
|
+
saveOAuthState(state, this.config.clientId);
|
|
629
|
+
savePkceVerifier(`${this.config.clientId}_popup_nonce`, popupNonce);
|
|
630
|
+
if (options.returnUrl) {
|
|
631
|
+
this._saveReturnUrlIfTrusted(options.returnUrl);
|
|
632
|
+
}
|
|
633
|
+
const authorizeEndpoint = await this._getAuthorizeEndpoint();
|
|
634
|
+
const params = new URLSearchParams();
|
|
635
|
+
params.set("response_type", "code");
|
|
636
|
+
params.set("client_id", this.config.clientId);
|
|
637
|
+
params.set("redirect_uri", this.config.redirectUri);
|
|
638
|
+
params.set("scope", this.config.scopes || "openid profile");
|
|
639
|
+
params.set("state", state);
|
|
640
|
+
params.set("code_challenge", challenge);
|
|
641
|
+
params.set("code_challenge_method", "S256");
|
|
642
|
+
params.set("popup_nonce", popupNonce);
|
|
643
|
+
const width = options.width || 480;
|
|
644
|
+
const height = options.height || 640;
|
|
645
|
+
const left = Math.max(0, (window.screen.width - width) / 2);
|
|
646
|
+
const top = Math.max(0, (window.screen.height - height) / 2);
|
|
647
|
+
const popupFeatures = [
|
|
648
|
+
`width=${width}`,
|
|
649
|
+
`height=${height}`,
|
|
650
|
+
`left=${Math.round(left)}`,
|
|
651
|
+
`top=${Math.round(top)}`,
|
|
652
|
+
"resizable=yes",
|
|
653
|
+
"scrollbars=yes",
|
|
654
|
+
"status=yes"
|
|
655
|
+
].join(",");
|
|
656
|
+
const popup = window.open(
|
|
657
|
+
`${authorizeEndpoint}?${params.toString()}`,
|
|
658
|
+
"nihplod_sso_popup",
|
|
659
|
+
popupFeatures
|
|
660
|
+
);
|
|
661
|
+
if (!popup) {
|
|
662
|
+
removePkceVerifier(this.config.clientId);
|
|
663
|
+
removeOAuthState(this.config.clientId);
|
|
664
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
665
|
+
throw new SsoError(
|
|
666
|
+
"popup_blocked",
|
|
667
|
+
"\u5F39\u7A97\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5"
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
const redirectUriOrigin = new URL(this.config.redirectUri).origin;
|
|
671
|
+
try {
|
|
672
|
+
popup.focus();
|
|
673
|
+
return await new Promise((resolve, reject) => {
|
|
674
|
+
let completed = false;
|
|
675
|
+
const handleMessage = (event) => {
|
|
676
|
+
if (completed) return;
|
|
677
|
+
if (event.origin !== redirectUriOrigin) return;
|
|
678
|
+
if (!event.data || event.data.type !== "nihplod_sso_popup_callback") return;
|
|
679
|
+
if (!event.data.callbackUrl) return;
|
|
680
|
+
const savedNonce = getPkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
681
|
+
if (!savedNonce || !timingSafeEqualString(event.data.nonce ?? "", savedNonce)) return;
|
|
682
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
683
|
+
completed = true;
|
|
684
|
+
cleanup();
|
|
685
|
+
if (popup && !popup.closed) {
|
|
686
|
+
popup.close();
|
|
687
|
+
}
|
|
688
|
+
this.handleCallback(event.data.callbackUrl).then(resolve).catch(reject);
|
|
689
|
+
};
|
|
690
|
+
const pollTimer = setInterval(() => {
|
|
691
|
+
if (popup.closed) {
|
|
692
|
+
if (!completed) {
|
|
693
|
+
completed = true;
|
|
694
|
+
cleanup();
|
|
695
|
+
reject(
|
|
696
|
+
new SsoError(
|
|
697
|
+
"popup_closed",
|
|
698
|
+
"\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED"
|
|
699
|
+
)
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}, 500);
|
|
704
|
+
const cleanup = () => {
|
|
705
|
+
clearInterval(pollTimer);
|
|
706
|
+
window.removeEventListener("message", handleMessage);
|
|
707
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
708
|
+
};
|
|
709
|
+
window.addEventListener("message", handleMessage);
|
|
710
|
+
});
|
|
711
|
+
} catch (err) {
|
|
712
|
+
removePkceVerifier(this.config.clientId);
|
|
713
|
+
removeOAuthState(this.config.clientId);
|
|
714
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
715
|
+
throw err;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
416
718
|
/**
|
|
417
719
|
* 处理 OAuth 回调
|
|
418
720
|
*
|
|
419
721
|
* 解析回调 URL,校验 state 参数,用授权码交换 token。
|
|
420
|
-
* 成功后 token 自动保存到 sessionStorage
|
|
722
|
+
* 成功后 token 自动保存到 token 存储(默认 sessionStorage,可通过 setTokenStorage 定制)。
|
|
421
723
|
*
|
|
422
724
|
* @param callbackUrl - 完整的回调 URL(window.location.href)
|
|
423
725
|
* @returns TokenData 或 null
|
|
@@ -427,23 +729,27 @@ var _SsoClient = class _SsoClient {
|
|
|
427
729
|
const params = url.searchParams;
|
|
428
730
|
const error = params.get("error");
|
|
429
731
|
if (error) {
|
|
732
|
+
removeOAuthState(this.config.clientId);
|
|
733
|
+
removePkceVerifier(this.config.clientId);
|
|
430
734
|
const desc = params.get("error_description") || error;
|
|
431
|
-
throw new SsoError(
|
|
735
|
+
throw new SsoError(mapOAuthErrorToSsoCode(error), `\u6388\u6743\u5931\u8D25: ${desc}`);
|
|
432
736
|
}
|
|
433
737
|
const code = params.get("code");
|
|
434
738
|
const returnedState = params.get("state");
|
|
435
739
|
if (!code) {
|
|
740
|
+
removeOAuthState(this.config.clientId);
|
|
741
|
+
removePkceVerifier(this.config.clientId);
|
|
436
742
|
throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
|
|
437
743
|
}
|
|
438
744
|
const savedState = getOAuthState(this.config.clientId);
|
|
439
|
-
if (!savedState || savedState
|
|
745
|
+
if (!savedState || !timingSafeEqualString(savedState, returnedState ?? "")) {
|
|
440
746
|
removeOAuthState(this.config.clientId);
|
|
747
|
+
removePkceVerifier(this.config.clientId);
|
|
441
748
|
throw new SsoError(
|
|
442
749
|
"state_mismatch",
|
|
443
750
|
"State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
|
|
444
751
|
);
|
|
445
752
|
}
|
|
446
|
-
removeOAuthState(this.config.clientId);
|
|
447
753
|
const verifier = getPkceVerifier(this.config.clientId);
|
|
448
754
|
if (!verifier) {
|
|
449
755
|
throw new SsoError(
|
|
@@ -451,7 +757,6 @@ var _SsoClient = class _SsoClient {
|
|
|
451
757
|
"code_verifier \u4E0D\u5B58\u5728\uFF08\u53EF\u80FD\u5DF2\u8FC7\u671F\u6216\u6765\u81EA\u5176\u4ED6\u6807\u7B7E\u9875\uFF09"
|
|
452
758
|
);
|
|
453
759
|
}
|
|
454
|
-
removePkceVerifier(this.config.clientId);
|
|
455
760
|
const tokenEndpoint = await this._getTokenEndpoint();
|
|
456
761
|
const body = new URLSearchParams();
|
|
457
762
|
body.set("grant_type", "authorization_code");
|
|
@@ -478,8 +783,9 @@ var _SsoClient = class _SsoClient {
|
|
|
478
783
|
errData = await res.json();
|
|
479
784
|
} catch {
|
|
480
785
|
}
|
|
786
|
+
const serverError = errData.error || "";
|
|
481
787
|
throw new SsoError(
|
|
482
|
-
|
|
788
|
+
mapOAuthErrorToSsoCode(serverError),
|
|
483
789
|
errData.error_description || `Token \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}`
|
|
484
790
|
);
|
|
485
791
|
}
|
|
@@ -488,15 +794,17 @@ var _SsoClient = class _SsoClient {
|
|
|
488
794
|
try {
|
|
489
795
|
await validateIdToken(
|
|
490
796
|
data.id_token,
|
|
797
|
+
data.access_token,
|
|
491
798
|
this.config.ssoBaseUrl,
|
|
492
|
-
this.config.clientId
|
|
493
|
-
data.access_token
|
|
799
|
+
this.config.clientId
|
|
494
800
|
);
|
|
495
801
|
} catch (err) {
|
|
496
802
|
removeTokenData(this.config.clientId);
|
|
497
803
|
throw err;
|
|
498
804
|
}
|
|
499
805
|
}
|
|
806
|
+
removeOAuthState(this.config.clientId);
|
|
807
|
+
removePkceVerifier(this.config.clientId);
|
|
500
808
|
const now = Date.now();
|
|
501
809
|
const tokenData = {
|
|
502
810
|
access_token: data.access_token,
|
|
@@ -539,15 +847,24 @@ var _SsoClient = class _SsoClient {
|
|
|
539
847
|
if (this.config.clientSecret) {
|
|
540
848
|
body.set("client_secret", this.config.clientSecret);
|
|
541
849
|
}
|
|
542
|
-
let res;
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
850
|
+
let res = null;
|
|
851
|
+
let lastErr;
|
|
852
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
853
|
+
try {
|
|
854
|
+
res = await fetch(tokenEndpoint, {
|
|
855
|
+
method: "POST",
|
|
856
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
857
|
+
body: body.toString()
|
|
858
|
+
});
|
|
859
|
+
lastErr = null;
|
|
860
|
+
break;
|
|
861
|
+
} catch (err) {
|
|
862
|
+
lastErr = err;
|
|
863
|
+
if (attempt < 1) await new Promise((r) => setTimeout(r, 1e3));
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
if (lastErr || !res) {
|
|
867
|
+
throw new SsoError("network_error", "\u5237\u65B0 Token \u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", lastErr);
|
|
551
868
|
}
|
|
552
869
|
if (!res.ok) {
|
|
553
870
|
let errData = {};
|
|
@@ -560,17 +877,31 @@ var _SsoClient = class _SsoClient {
|
|
|
560
877
|
removeTokenData(this.config.clientId);
|
|
561
878
|
}
|
|
562
879
|
throw new SsoError(
|
|
563
|
-
errorCode
|
|
880
|
+
mapOAuthErrorToSsoCode(errorCode, "refresh"),
|
|
564
881
|
errData.error_description || `\u5237\u65B0 Token \u5931\u8D25: HTTP ${res.status}`
|
|
565
882
|
);
|
|
566
883
|
}
|
|
567
884
|
const data = await res.json();
|
|
885
|
+
if (data.id_token) {
|
|
886
|
+
try {
|
|
887
|
+
await validateIdToken(
|
|
888
|
+
data.id_token,
|
|
889
|
+
data.access_token,
|
|
890
|
+
this.config.ssoBaseUrl,
|
|
891
|
+
this.config.clientId
|
|
892
|
+
);
|
|
893
|
+
} catch (err) {
|
|
894
|
+
removeTokenData(this.config.clientId);
|
|
895
|
+
throw err;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
568
898
|
const now = Date.now();
|
|
569
899
|
const tokenData = {
|
|
570
900
|
access_token: data.access_token,
|
|
571
901
|
token_type: data.token_type,
|
|
572
902
|
expires_in: data.expires_in,
|
|
573
|
-
|
|
903
|
+
// RFC 6749 §6:刷新响应可省略 refresh_token,此时沿用旧值
|
|
904
|
+
refresh_token: data.refresh_token || current.refresh_token,
|
|
574
905
|
id_token: data.id_token,
|
|
575
906
|
issued_at: now,
|
|
576
907
|
expires_at: now + data.expires_in * 1e3
|
|
@@ -642,7 +973,9 @@ var _SsoClient = class _SsoClient {
|
|
|
642
973
|
* 登出
|
|
643
974
|
*
|
|
644
975
|
* 清除本地所有 token 和临时数据,并尝试撤销服务端 refresh_token。
|
|
645
|
-
* @param redirectToSso - 是否重定向到 SSO 登出页(默认 false
|
|
976
|
+
* @param redirectToSso - 是否重定向到 SSO 登出页(默认 false)。
|
|
977
|
+
* 为 true 时携带 state 参数(已保存到 sessionStorage),
|
|
978
|
+
* 回跳页面应调用 validateLogoutState() 校验防登出 CSRF。
|
|
646
979
|
*/
|
|
647
980
|
async logout(redirectToSso = false) {
|
|
648
981
|
const tokenData = getTokenData(this.config.clientId);
|
|
@@ -671,23 +1004,50 @@ var _SsoClient = class _SsoClient {
|
|
|
671
1004
|
}
|
|
672
1005
|
if (redirectToSso) {
|
|
673
1006
|
const discovery = await this._getDiscovery();
|
|
674
|
-
const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/
|
|
1007
|
+
const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/api/oauth/end-session`;
|
|
675
1008
|
const logoutUrl = new URL(endSessionEndpoint);
|
|
676
1009
|
logoutUrl.searchParams.set("client_id", this.config.clientId);
|
|
677
|
-
|
|
1010
|
+
const postLogoutUri = this.config.postLogoutRedirectUri || this.config.redirectUri;
|
|
1011
|
+
if (postLogoutUri) {
|
|
678
1012
|
logoutUrl.searchParams.set(
|
|
679
1013
|
"post_logout_redirect_uri",
|
|
680
|
-
|
|
1014
|
+
postLogoutUri
|
|
681
1015
|
);
|
|
682
1016
|
}
|
|
683
1017
|
if (idTokenHint) {
|
|
684
1018
|
logoutUrl.searchParams.set("id_token_hint", idTokenHint);
|
|
685
1019
|
}
|
|
686
1020
|
const state = generateState();
|
|
1021
|
+
saveLogoutState(state, this.config.clientId);
|
|
687
1022
|
logoutUrl.searchParams.set("state", state);
|
|
688
1023
|
window.location.href = logoutUrl.toString();
|
|
689
1024
|
}
|
|
690
1025
|
}
|
|
1026
|
+
/**
|
|
1027
|
+
* 校验 RP-Initiated Logout 回跳的 state 参数(登出 CSRF 防护)
|
|
1028
|
+
*
|
|
1029
|
+
* 在 post_logout_redirect_uri 指向的页面加载时调用;
|
|
1030
|
+
* 仅在 URL 携带 state 且与 logout(redirectToSso=true) 保存的值一致时返回 true,
|
|
1031
|
+
* 校验后清除已保存的 logout state(一次性)。
|
|
1032
|
+
*
|
|
1033
|
+
* @param url - 当前页面完整 URL(window.location.href)
|
|
1034
|
+
*
|
|
1035
|
+
* @example
|
|
1036
|
+
* ```typescript
|
|
1037
|
+
* if (sso.validateLogoutState(window.location.href)) {
|
|
1038
|
+
* // 来自 SSO 登出的可信回跳
|
|
1039
|
+
* }
|
|
1040
|
+
* ```
|
|
1041
|
+
*/
|
|
1042
|
+
validateLogoutState(url) {
|
|
1043
|
+
const returnedState = new URL(url).searchParams.get("state");
|
|
1044
|
+
if (!returnedState) return false;
|
|
1045
|
+
const savedState = getLogoutState(this.config.clientId);
|
|
1046
|
+
if (!savedState) return false;
|
|
1047
|
+
if (!timingSafeEqualString(savedState, returnedState)) return false;
|
|
1048
|
+
removeLogoutState(this.config.clientId);
|
|
1049
|
+
return true;
|
|
1050
|
+
}
|
|
691
1051
|
/**
|
|
692
1052
|
* 获取 OIDC Discovery 文档
|
|
693
1053
|
*
|
|
@@ -708,6 +1068,7 @@ var SsoClient = _SsoClient;
|
|
|
708
1068
|
var SsoContext = createContext(null);
|
|
709
1069
|
var REFRESH_LOCK_PREFIX = "nihplod_sso_refresh_lock:";
|
|
710
1070
|
var LOCK_TTL_MS = 5e3;
|
|
1071
|
+
var ownedLocks = /* @__PURE__ */ new Map();
|
|
711
1072
|
function lockKey(clientId) {
|
|
712
1073
|
return REFRESH_LOCK_PREFIX + clientId;
|
|
713
1074
|
}
|
|
@@ -722,12 +1083,44 @@ function acquireRefreshLock(clientId) {
|
|
|
722
1083
|
return false;
|
|
723
1084
|
}
|
|
724
1085
|
}
|
|
725
|
-
|
|
1086
|
+
const token = `${now}:${Math.random().toString(36).slice(2)}`;
|
|
1087
|
+
localStorage.setItem(key, token);
|
|
1088
|
+
if (localStorage.getItem(key) !== token) return false;
|
|
1089
|
+
ownedLocks.set(clientId, token);
|
|
726
1090
|
return true;
|
|
727
1091
|
}
|
|
728
1092
|
function releaseRefreshLock(clientId) {
|
|
729
1093
|
if (typeof localStorage === "undefined") return;
|
|
730
|
-
|
|
1094
|
+
const owned = ownedLocks.get(clientId);
|
|
1095
|
+
ownedLocks.delete(clientId);
|
|
1096
|
+
if (owned && localStorage.getItem(lockKey(clientId)) === owned) {
|
|
1097
|
+
localStorage.removeItem(lockKey(clientId));
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
async function withRefreshLock(clientId, task) {
|
|
1101
|
+
if (typeof navigator !== "undefined" && typeof navigator.locks?.request === "function") {
|
|
1102
|
+
try {
|
|
1103
|
+
let ran = false;
|
|
1104
|
+
await navigator.locks.request(
|
|
1105
|
+
`nihplod_sso_refresh_${clientId}`,
|
|
1106
|
+
{ ifAvailable: true },
|
|
1107
|
+
async (lock) => {
|
|
1108
|
+
if (!lock) return;
|
|
1109
|
+
ran = true;
|
|
1110
|
+
await task();
|
|
1111
|
+
}
|
|
1112
|
+
);
|
|
1113
|
+
return ran;
|
|
1114
|
+
} catch {
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
if (!acquireRefreshLock(clientId)) return false;
|
|
1118
|
+
try {
|
|
1119
|
+
await task();
|
|
1120
|
+
return true;
|
|
1121
|
+
} finally {
|
|
1122
|
+
releaseRefreshLock(clientId);
|
|
1123
|
+
}
|
|
731
1124
|
}
|
|
732
1125
|
function SsoProvider({
|
|
733
1126
|
config,
|
|
@@ -738,14 +1131,16 @@ function SsoProvider({
|
|
|
738
1131
|
const [user, setUser] = useState(null);
|
|
739
1132
|
const [isLoading, setIsLoading] = useState(true);
|
|
740
1133
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
|
741
|
-
const
|
|
1134
|
+
const [error, setError] = useState(null);
|
|
1135
|
+
const [client] = useState(() => new SsoClient(config));
|
|
742
1136
|
const refreshTimerRef = useRef(null);
|
|
743
1137
|
const loadedRef = useRef(false);
|
|
744
|
-
const client = clientRef.current;
|
|
745
1138
|
const loadUser = useCallback(async () => {
|
|
746
|
-
|
|
1139
|
+
const tokenData = getTokenData(client.config.clientId);
|
|
1140
|
+
if (!tokenData) {
|
|
747
1141
|
setUser(null);
|
|
748
1142
|
setIsAuthenticated(false);
|
|
1143
|
+
setError(null);
|
|
749
1144
|
setIsLoading(false);
|
|
750
1145
|
return;
|
|
751
1146
|
}
|
|
@@ -753,9 +1148,13 @@ function SsoProvider({
|
|
|
753
1148
|
const u = await client.getUserInfo();
|
|
754
1149
|
setUser(u);
|
|
755
1150
|
setIsAuthenticated(true);
|
|
756
|
-
|
|
1151
|
+
setError(null);
|
|
1152
|
+
} catch (err) {
|
|
757
1153
|
setUser(null);
|
|
758
1154
|
setIsAuthenticated(false);
|
|
1155
|
+
setError(
|
|
1156
|
+
err instanceof SsoError ? err : new SsoError("userinfo_failed", err instanceof Error ? err.message : String(err))
|
|
1157
|
+
);
|
|
759
1158
|
removeTokenData(client.config.clientId);
|
|
760
1159
|
} finally {
|
|
761
1160
|
setIsLoading(false);
|
|
@@ -764,14 +1163,25 @@ function SsoProvider({
|
|
|
764
1163
|
useEffect(() => {
|
|
765
1164
|
if (loadedRef.current) return;
|
|
766
1165
|
loadedRef.current = true;
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
1166
|
+
Promise.resolve().then(() => {
|
|
1167
|
+
if (getTokenData(client.config.clientId)) {
|
|
1168
|
+
loadUser();
|
|
1169
|
+
} else {
|
|
1170
|
+
setIsLoading(false);
|
|
1171
|
+
}
|
|
1172
|
+
});
|
|
772
1173
|
}, [client, loadUser]);
|
|
773
1174
|
useEffect(() => {
|
|
774
1175
|
let active = true;
|
|
1176
|
+
const attemptRefresh = () => withRefreshLock(client.config.clientId, async () => {
|
|
1177
|
+
try {
|
|
1178
|
+
const td = await client.refreshToken();
|
|
1179
|
+
onTokenRefreshed?.(td.access_token);
|
|
1180
|
+
loadUser();
|
|
1181
|
+
} catch {
|
|
1182
|
+
setTimeout(() => loadUser(), 500);
|
|
1183
|
+
}
|
|
1184
|
+
});
|
|
775
1185
|
const scheduleNextRefresh = () => {
|
|
776
1186
|
if (!active) return;
|
|
777
1187
|
if (refreshTimerRef.current) {
|
|
@@ -782,41 +1192,23 @@ function SsoProvider({
|
|
|
782
1192
|
if (!tokenData) return;
|
|
783
1193
|
const remainingSec = (tokenData.expires_at - Date.now()) / 1e3;
|
|
784
1194
|
if (remainingSec <= 0) {
|
|
785
|
-
|
|
786
|
-
client.refreshToken().then((td) => {
|
|
787
|
-
onTokenRefreshed?.(td.access_token);
|
|
788
|
-
loadUser();
|
|
789
|
-
}).catch(() => {
|
|
790
|
-
setTimeout(() => loadUser(), 500);
|
|
791
|
-
}).finally(() => releaseRefreshLock(client.config.clientId));
|
|
1195
|
+
void attemptRefresh();
|
|
792
1196
|
return;
|
|
793
1197
|
}
|
|
794
1198
|
if (remainingSec <= refreshThreshold) {
|
|
795
|
-
|
|
796
|
-
client.refreshToken().then((td) => {
|
|
797
|
-
onTokenRefreshed?.(td.access_token);
|
|
798
|
-
loadUser();
|
|
799
|
-
}).catch(() => {
|
|
800
|
-
setTimeout(() => loadUser(), 500);
|
|
801
|
-
}).finally(() => releaseRefreshLock(client.config.clientId));
|
|
1199
|
+
void attemptRefresh();
|
|
802
1200
|
return;
|
|
803
1201
|
}
|
|
804
1202
|
const delayMs = (remainingSec - refreshThreshold) * 1e3;
|
|
805
1203
|
refreshTimerRef.current = setTimeout(() => {
|
|
1204
|
+
if (!active) return;
|
|
806
1205
|
const td = getTokenData(client.config.clientId);
|
|
807
1206
|
if (!td) return;
|
|
808
1207
|
const secLeft = (td.expires_at - Date.now()) / 1e3;
|
|
809
1208
|
if (secLeft <= refreshThreshold) {
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
}
|
|
813
|
-
client.refreshToken().then((newTd) => {
|
|
814
|
-
onTokenRefreshed?.(newTd.access_token);
|
|
815
|
-
loadUser();
|
|
816
|
-
}).catch(() => {
|
|
817
|
-
setTimeout(() => loadUser(), 500);
|
|
818
|
-
}).finally(() => releaseRefreshLock(client.config.clientId));
|
|
819
|
-
scheduleNextRefresh();
|
|
1209
|
+
void attemptRefresh().then((didRefresh) => {
|
|
1210
|
+
if (didRefresh) scheduleNextRefresh();
|
|
1211
|
+
});
|
|
820
1212
|
}
|
|
821
1213
|
}, Math.max(delayMs, 1e3));
|
|
822
1214
|
};
|
|
@@ -844,11 +1236,20 @@ function SsoProvider({
|
|
|
844
1236
|
},
|
|
845
1237
|
[client]
|
|
846
1238
|
);
|
|
1239
|
+
const loginPopup = useCallback(
|
|
1240
|
+
async (options) => {
|
|
1241
|
+
const tokenData = await client.loginPopup(options);
|
|
1242
|
+
await loadUser();
|
|
1243
|
+
return tokenData;
|
|
1244
|
+
},
|
|
1245
|
+
[client, loadUser]
|
|
1246
|
+
);
|
|
847
1247
|
const logout = useCallback(
|
|
848
1248
|
async (redirectToSso = false) => {
|
|
849
1249
|
await client.logout(redirectToSso);
|
|
850
1250
|
setUser(null);
|
|
851
1251
|
setIsAuthenticated(false);
|
|
1252
|
+
setError(null);
|
|
852
1253
|
},
|
|
853
1254
|
[client]
|
|
854
1255
|
);
|
|
@@ -862,7 +1263,9 @@ function SsoProvider({
|
|
|
862
1263
|
user,
|
|
863
1264
|
isAuthenticated,
|
|
864
1265
|
isLoading,
|
|
1266
|
+
error,
|
|
865
1267
|
login,
|
|
1268
|
+
loginPopup,
|
|
866
1269
|
logout,
|
|
867
1270
|
refreshUser,
|
|
868
1271
|
getAccessToken,
|
|
@@ -879,25 +1282,67 @@ function useSso() {
|
|
|
879
1282
|
}
|
|
880
1283
|
|
|
881
1284
|
// src/react/RequireAuth.tsx
|
|
882
|
-
import React2, { useEffect as useEffect2, useRef as useRef2 } from "react";
|
|
1285
|
+
import React2, { useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
|
|
883
1286
|
function RequireAuth({
|
|
884
1287
|
children,
|
|
885
1288
|
fallback,
|
|
886
|
-
autoLogin = true
|
|
1289
|
+
autoLogin = true,
|
|
1290
|
+
usePopup = false,
|
|
1291
|
+
onError,
|
|
1292
|
+
renderLoginError
|
|
887
1293
|
}) {
|
|
888
|
-
const { isAuthenticated, isLoading, login } = useSso();
|
|
1294
|
+
const { isAuthenticated, isLoading, login, loginPopup } = useSso();
|
|
889
1295
|
const loginTriggeredRef = useRef2(false);
|
|
1296
|
+
const [loginError, setLoginError] = useState2(null);
|
|
1297
|
+
const triggerLogin = () => {
|
|
1298
|
+
loginTriggeredRef.current = true;
|
|
1299
|
+
const currentPath = window.location.pathname + window.location.search;
|
|
1300
|
+
const handleFailure = (err) => {
|
|
1301
|
+
loginTriggeredRef.current = false;
|
|
1302
|
+
setLoginError(err);
|
|
1303
|
+
onError?.(err);
|
|
1304
|
+
};
|
|
1305
|
+
if (usePopup) {
|
|
1306
|
+
loginPopup({ returnUrl: currentPath }).catch((err) => {
|
|
1307
|
+
if (err.code === "popup_blocked") {
|
|
1308
|
+
login(currentPath).catch(handleFailure);
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
handleFailure(err);
|
|
1312
|
+
});
|
|
1313
|
+
} else {
|
|
1314
|
+
login(currentPath).catch(handleFailure);
|
|
1315
|
+
}
|
|
1316
|
+
};
|
|
1317
|
+
const retry = () => {
|
|
1318
|
+
setLoginError(null);
|
|
1319
|
+
triggerLogin();
|
|
1320
|
+
};
|
|
890
1321
|
useEffect2(() => {
|
|
891
1322
|
if (!isLoading && !isAuthenticated && autoLogin && !loginTriggeredRef.current) {
|
|
892
|
-
|
|
893
|
-
const currentPath = window.location.pathname + window.location.search;
|
|
894
|
-
login(currentPath);
|
|
1323
|
+
triggerLogin();
|
|
895
1324
|
}
|
|
896
|
-
}, [isLoading, isAuthenticated, autoLogin, login]);
|
|
1325
|
+
}, [isLoading, isAuthenticated, autoLogin, usePopup, login, loginPopup]);
|
|
897
1326
|
if (isLoading) {
|
|
898
1327
|
return fallback || React2.createElement("div", null, "\u6B63\u5728\u9A8C\u8BC1\u767B\u5F55\u72B6\u6001...");
|
|
899
1328
|
}
|
|
900
1329
|
if (!isAuthenticated) {
|
|
1330
|
+
if (loginError) {
|
|
1331
|
+
if (renderLoginError) {
|
|
1332
|
+
return React2.createElement(
|
|
1333
|
+
React2.Fragment,
|
|
1334
|
+
null,
|
|
1335
|
+
renderLoginError(loginError, retry)
|
|
1336
|
+
);
|
|
1337
|
+
}
|
|
1338
|
+
const message = loginError instanceof Error ? loginError.message : "\u767B\u5F55\u5931\u8D25";
|
|
1339
|
+
return React2.createElement(
|
|
1340
|
+
"div",
|
|
1341
|
+
null,
|
|
1342
|
+
React2.createElement("p", null, `\u767B\u5F55\u672A\u5B8C\u6210\uFF1A${message}`),
|
|
1343
|
+
React2.createElement("button", { onClick: retry }, "\u91CD\u8BD5\u767B\u5F55")
|
|
1344
|
+
);
|
|
1345
|
+
}
|
|
901
1346
|
if (fallback) return React2.createElement(React2.Fragment, null, fallback);
|
|
902
1347
|
return React2.createElement("div", null, "\u8BF7\u5148\u767B\u5F55");
|
|
903
1348
|
}
|
|
@@ -917,31 +1362,81 @@ function withAuth(Component) {
|
|
|
917
1362
|
}
|
|
918
1363
|
|
|
919
1364
|
// src/react/CallbackPage.tsx
|
|
920
|
-
import React3, { useEffect as useEffect3, useState as
|
|
921
|
-
function
|
|
1365
|
+
import React3, { useEffect as useEffect3, useRef as useRef3, useState as useState3 } from "react";
|
|
1366
|
+
function DefaultCallbackError({ error }) {
|
|
1367
|
+
return React3.createElement(
|
|
1368
|
+
"div",
|
|
1369
|
+
{
|
|
1370
|
+
style: {
|
|
1371
|
+
display: "flex",
|
|
1372
|
+
flexDirection: "column",
|
|
1373
|
+
alignItems: "center",
|
|
1374
|
+
justifyContent: "center",
|
|
1375
|
+
minHeight: "100vh",
|
|
1376
|
+
fontFamily: "system-ui, sans-serif"
|
|
1377
|
+
}
|
|
1378
|
+
},
|
|
1379
|
+
React3.createElement(
|
|
1380
|
+
"p",
|
|
1381
|
+
{ style: { color: "#dc2626", marginBottom: "1rem" } },
|
|
1382
|
+
error
|
|
1383
|
+
),
|
|
1384
|
+
React3.createElement(
|
|
1385
|
+
"a",
|
|
1386
|
+
{ href: "/", style: { color: "#2563eb", textDecoration: "underline" } },
|
|
1387
|
+
"\u8FD4\u56DE\u9996\u9875"
|
|
1388
|
+
)
|
|
1389
|
+
);
|
|
1390
|
+
}
|
|
1391
|
+
function CallbackPage({ onSuccess, onError, renderError } = {}) {
|
|
922
1392
|
const { client, refreshUser } = useSso();
|
|
923
|
-
const [error, setError] =
|
|
924
|
-
const [processing, setProcessing] =
|
|
1393
|
+
const [error, setError] = useState3(null);
|
|
1394
|
+
const [processing, setProcessing] = useState3(true);
|
|
1395
|
+
const onSuccessRef = useRef3(onSuccess);
|
|
1396
|
+
const onErrorRef = useRef3(onError);
|
|
1397
|
+
useEffect3(() => {
|
|
1398
|
+
onSuccessRef.current = onSuccess;
|
|
1399
|
+
onErrorRef.current = onError;
|
|
1400
|
+
});
|
|
925
1401
|
useEffect3(() => {
|
|
1402
|
+
if (window.opener && !window.opener.closed) {
|
|
1403
|
+
const nonce = new URL(window.location.href).searchParams.get("popup_nonce");
|
|
1404
|
+
let targetOrigin = window.location.origin;
|
|
1405
|
+
try {
|
|
1406
|
+
targetOrigin = window.opener.location.origin;
|
|
1407
|
+
} catch {
|
|
1408
|
+
}
|
|
1409
|
+
window.opener.postMessage(
|
|
1410
|
+
{ type: "nihplod_sso_popup_callback", callbackUrl: window.location.href, nonce: nonce || void 0 },
|
|
1411
|
+
targetOrigin
|
|
1412
|
+
);
|
|
1413
|
+
Promise.resolve().then(() => setProcessing(false));
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
926
1416
|
let cancelled = false;
|
|
927
1417
|
async function handleCallback() {
|
|
928
1418
|
try {
|
|
929
|
-
await client.handleCallback(window.location.href);
|
|
1419
|
+
const tokenData = await client.handleCallback(window.location.href);
|
|
930
1420
|
if (cancelled) return;
|
|
931
1421
|
await refreshUser();
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1422
|
+
if (cancelled) return;
|
|
1423
|
+
const clientId = client.config.clientId;
|
|
1424
|
+
const returnUrl = getReturnUrl(clientId);
|
|
1425
|
+
removeReturnUrl(clientId);
|
|
1426
|
+
const onSuccessCb = onSuccessRef.current;
|
|
1427
|
+
if (onSuccessCb) {
|
|
1428
|
+
onSuccessCb(tokenData);
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
window.location.href = returnUrl && isTrustedReturnUrl(returnUrl, window.location.origin) ? returnUrl : "/";
|
|
935
1432
|
} catch (err) {
|
|
936
1433
|
if (cancelled) return;
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
setError(
|
|
941
|
-
} else if (err instanceof Error) {
|
|
942
|
-
setError(`\u767B\u5F55\u56DE\u8C03\u5904\u7406\u5931\u8D25: ${err.message}`);
|
|
1434
|
+
const errorObj = err instanceof Error ? err : new Error(String(err));
|
|
1435
|
+
onErrorRef.current?.(errorObj);
|
|
1436
|
+
if (errorObj instanceof SsoError) {
|
|
1437
|
+
setError(errorObj.description || `SSO \u9519\u8BEF (${errorObj.code})`);
|
|
943
1438
|
} else {
|
|
944
|
-
setError(
|
|
1439
|
+
setError(`\u767B\u5F55\u56DE\u8C03\u5904\u7406\u5931\u8D25: ${errorObj.message}`);
|
|
945
1440
|
}
|
|
946
1441
|
setProcessing(false);
|
|
947
1442
|
}
|
|
@@ -952,29 +1447,8 @@ function CallbackPage() {
|
|
|
952
1447
|
};
|
|
953
1448
|
}, [client, refreshUser]);
|
|
954
1449
|
if (error) {
|
|
955
|
-
return React3.createElement(
|
|
956
|
-
|
|
957
|
-
{
|
|
958
|
-
style: {
|
|
959
|
-
display: "flex",
|
|
960
|
-
flexDirection: "column",
|
|
961
|
-
alignItems: "center",
|
|
962
|
-
justifyContent: "center",
|
|
963
|
-
minHeight: "100vh",
|
|
964
|
-
fontFamily: "system-ui, sans-serif"
|
|
965
|
-
}
|
|
966
|
-
},
|
|
967
|
-
React3.createElement(
|
|
968
|
-
"p",
|
|
969
|
-
{ style: { color: "#dc2626", marginBottom: "1rem" } },
|
|
970
|
-
error
|
|
971
|
-
),
|
|
972
|
-
React3.createElement(
|
|
973
|
-
"a",
|
|
974
|
-
{ href: "/", style: { color: "#2563eb", textDecoration: "underline" } },
|
|
975
|
-
"\u8FD4\u56DE\u9996\u9875"
|
|
976
|
-
)
|
|
977
|
-
);
|
|
1450
|
+
if (renderError) return React3.createElement(React3.Fragment, null, renderError(error));
|
|
1451
|
+
return React3.createElement(DefaultCallbackError, { error });
|
|
978
1452
|
}
|
|
979
1453
|
if (processing) {
|
|
980
1454
|
return React3.createElement(
|
|
@@ -995,6 +1469,7 @@ function CallbackPage() {
|
|
|
995
1469
|
}
|
|
996
1470
|
export {
|
|
997
1471
|
CallbackPage,
|
|
1472
|
+
DefaultCallbackError,
|
|
998
1473
|
RequireAuth,
|
|
999
1474
|
SsoProvider,
|
|
1000
1475
|
useSso,
|