@nihplod/sso-sdk 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -3
- package/dist/index.d.mts +99 -12
- package/dist/index.d.ts +99 -12
- package/dist/index.js +413 -75
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +408 -74
- package/dist/index.mjs.map +1 -1
- package/dist/next/index.d.mts +17 -2
- package/dist/next/index.d.ts +17 -2
- package/dist/next/index.js +276 -45
- package/dist/next/index.js.map +1 -1
- package/dist/next/index.mjs +274 -45
- package/dist/next/index.mjs.map +1 -1
- package/dist/react/index.d.mts +71 -8
- package/dist/react/index.d.ts +71 -8
- package/dist/react/index.js +476 -111
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +476 -111
- package/dist/react/index.mjs.map +1 -1
- package/package.json +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}`);
|
|
@@ -81,6 +101,57 @@ function createMemoryStorageAdapter() {
|
|
|
81
101
|
};
|
|
82
102
|
}
|
|
83
103
|
var memoryStorageAdapter = createMemoryStorageAdapter();
|
|
104
|
+
var localStorageAdapter = {
|
|
105
|
+
get(key) {
|
|
106
|
+
if (typeof localStorage === "undefined") return null;
|
|
107
|
+
return localStorage.getItem(STORAGE_PREFIX + key);
|
|
108
|
+
},
|
|
109
|
+
set(key, value) {
|
|
110
|
+
if (typeof localStorage === "undefined") return;
|
|
111
|
+
try {
|
|
112
|
+
localStorage.setItem(STORAGE_PREFIX + key, value);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.warn(
|
|
115
|
+
`[SSO SDK] localStorage \u5199\u5165\u5931\u8D25\uFF08${err instanceof Error ? err.name : String(err)}\uFF09\uFF0C\u6570\u636E\u672A\u6301\u4E45\u5316`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
remove(key) {
|
|
120
|
+
if (typeof localStorage === "undefined") return;
|
|
121
|
+
localStorage.removeItem(STORAGE_PREFIX + key);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
function createSessionStorageAdapter() {
|
|
125
|
+
const fallback = /* @__PURE__ */ new Map();
|
|
126
|
+
return {
|
|
127
|
+
get(key) {
|
|
128
|
+
if (typeof sessionStorage !== "undefined") {
|
|
129
|
+
return sessionStorage.getItem(STORAGE_PREFIX + key) ?? fallback.get(key) ?? null;
|
|
130
|
+
}
|
|
131
|
+
return fallback.get(key) ?? null;
|
|
132
|
+
},
|
|
133
|
+
set(key, value) {
|
|
134
|
+
if (typeof sessionStorage === "undefined") {
|
|
135
|
+
fallback.set(key, value);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
sessionStorage.setItem(STORAGE_PREFIX + key, value);
|
|
140
|
+
} catch {
|
|
141
|
+
fallback.set(key, value);
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
remove(key) {
|
|
145
|
+
fallback.delete(key);
|
|
146
|
+
if (typeof sessionStorage === "undefined") return;
|
|
147
|
+
sessionStorage.removeItem(STORAGE_PREFIX + key);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
var _transient = createSessionStorageAdapter();
|
|
152
|
+
function createSecureStorage(options = {}) {
|
|
153
|
+
return options.persist ? localStorageAdapter : memoryStorageAdapter;
|
|
154
|
+
}
|
|
84
155
|
var _storage = memoryStorageAdapter;
|
|
85
156
|
function setTokenStorage(storage) {
|
|
86
157
|
_storage = storage;
|
|
@@ -104,31 +175,31 @@ function removeTokenData(clientId) {
|
|
|
104
175
|
_storage.remove(buildKey(TOKEN_KEY, clientId));
|
|
105
176
|
}
|
|
106
177
|
function savePkceVerifier(clientId, verifier) {
|
|
107
|
-
|
|
178
|
+
_transient.set(VERIFIER_KEY_PREFIX + clientId, verifier);
|
|
108
179
|
}
|
|
109
180
|
function getPkceVerifier(clientId) {
|
|
110
|
-
return
|
|
181
|
+
return _transient.get(VERIFIER_KEY_PREFIX + clientId);
|
|
111
182
|
}
|
|
112
183
|
function removePkceVerifier(clientId) {
|
|
113
|
-
|
|
184
|
+
_transient.remove(VERIFIER_KEY_PREFIX + clientId);
|
|
114
185
|
}
|
|
115
186
|
function saveOAuthState(state, clientId) {
|
|
116
|
-
|
|
187
|
+
_transient.set(buildKey(STATE_KEY, clientId), state);
|
|
117
188
|
}
|
|
118
189
|
function getOAuthState(clientId) {
|
|
119
|
-
return
|
|
190
|
+
return _transient.get(buildKey(STATE_KEY, clientId));
|
|
120
191
|
}
|
|
121
192
|
function removeOAuthState(clientId) {
|
|
122
|
-
|
|
193
|
+
_transient.remove(buildKey(STATE_KEY, clientId));
|
|
123
194
|
}
|
|
124
195
|
function saveReturnUrl(url, clientId) {
|
|
125
|
-
|
|
196
|
+
_transient.set(buildKey(RETURN_URL_KEY, clientId), url);
|
|
126
197
|
}
|
|
127
198
|
function getReturnUrl(clientId) {
|
|
128
|
-
return
|
|
199
|
+
return _transient.get(buildKey(RETURN_URL_KEY, clientId));
|
|
129
200
|
}
|
|
130
201
|
function removeReturnUrl(clientId) {
|
|
131
|
-
|
|
202
|
+
_transient.remove(buildKey(RETURN_URL_KEY, clientId));
|
|
132
203
|
}
|
|
133
204
|
function clearAllSsoData(clientId) {
|
|
134
205
|
if (clientId) {
|
|
@@ -136,28 +207,63 @@ function clearAllSsoData(clientId) {
|
|
|
136
207
|
removeOAuthState(clientId);
|
|
137
208
|
removeReturnUrl(clientId);
|
|
138
209
|
removePkceVerifier(clientId);
|
|
210
|
+
removePkceVerifier(`${clientId}_popup_nonce`);
|
|
139
211
|
return;
|
|
140
212
|
}
|
|
141
213
|
removeTokenData();
|
|
142
214
|
removeOAuthState();
|
|
143
215
|
removeReturnUrl();
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
216
|
+
const prefix = STORAGE_PREFIX + VERIFIER_KEY_PREFIX;
|
|
217
|
+
const stores = [
|
|
218
|
+
typeof sessionStorage !== "undefined" ? sessionStorage : null,
|
|
219
|
+
typeof localStorage !== "undefined" ? localStorage : null
|
|
220
|
+
];
|
|
221
|
+
for (const store of stores) {
|
|
222
|
+
if (!store) continue;
|
|
223
|
+
const keys = [];
|
|
224
|
+
for (let i = 0; i < store.length; i++) {
|
|
225
|
+
const key = store.key(i);
|
|
226
|
+
if (key?.startsWith(prefix)) keys.push(key);
|
|
227
|
+
}
|
|
228
|
+
for (const key of keys) {
|
|
229
|
+
_transient.remove(key.slice(STORAGE_PREFIX.length));
|
|
230
|
+
store.removeItem(key);
|
|
151
231
|
}
|
|
152
232
|
}
|
|
153
233
|
}
|
|
154
234
|
function clearVerifiersForClients(clientIds) {
|
|
155
235
|
for (const clientId of clientIds) {
|
|
156
|
-
|
|
236
|
+
_transient.remove(VERIFIER_KEY_PREFIX + clientId);
|
|
157
237
|
}
|
|
158
238
|
}
|
|
159
239
|
|
|
160
|
-
// src/core/
|
|
240
|
+
// src/core/security.ts
|
|
241
|
+
function isTrustedReturnUrl(url, currentOrigin) {
|
|
242
|
+
if (!url) return false;
|
|
243
|
+
if (url.startsWith("/") && !url.startsWith("//")) return true;
|
|
244
|
+
try {
|
|
245
|
+
return new URL(url).origin === currentOrigin;
|
|
246
|
+
} catch {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function timingSafeEqualString(a, b) {
|
|
251
|
+
const ba = new TextEncoder().encode(a);
|
|
252
|
+
const bb = new TextEncoder().encode(b);
|
|
253
|
+
if (ba.length === 0 || bb.length === 0) return ba.length === bb.length;
|
|
254
|
+
const len = Math.max(ba.length, bb.length);
|
|
255
|
+
let diff = ba.length ^ bb.length;
|
|
256
|
+
for (let i = 0; i < len; i++) {
|
|
257
|
+
diff |= ba[i % ba.length] ^ bb[i % bb.length];
|
|
258
|
+
}
|
|
259
|
+
return diff === 0;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// src/core/id-token.ts
|
|
263
|
+
var _crypto = typeof crypto !== "undefined" && crypto.subtle ? crypto : null;
|
|
264
|
+
function getCrypto() {
|
|
265
|
+
return _crypto ?? null;
|
|
266
|
+
}
|
|
161
267
|
function base64UrlDecode(input) {
|
|
162
268
|
const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
163
269
|
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
|
|
@@ -192,14 +298,32 @@ function normalizeIssuer(url) {
|
|
|
192
298
|
return url.replace(/\/+$/, "");
|
|
193
299
|
}
|
|
194
300
|
var cachedJwks = null;
|
|
301
|
+
var cachedDiscovery = null;
|
|
195
302
|
var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
196
|
-
async function
|
|
303
|
+
async function fetchDiscoveryDoc(baseUrl) {
|
|
197
304
|
const now = Date.now();
|
|
198
|
-
if (
|
|
305
|
+
if (cachedDiscovery && cachedDiscovery.baseUrl === baseUrl && now - cachedDiscovery.fetchedAt < JWKS_CACHE_TTL_MS) {
|
|
306
|
+
return cachedDiscovery.doc;
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
const res = await fetch(`${baseUrl}/api/oauth/.well-known/openid-configuration`);
|
|
310
|
+
const doc = res.ok ? await res.json() : null;
|
|
311
|
+
cachedDiscovery = { baseUrl, doc, fetchedAt: now };
|
|
312
|
+
return doc;
|
|
313
|
+
} catch {
|
|
314
|
+
cachedDiscovery = { baseUrl, doc: null, fetchedAt: now };
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
async function fetchJwks(baseUrl, options = {}) {
|
|
319
|
+
const now = Date.now();
|
|
320
|
+
if (!options.forceRefresh && cachedJwks && cachedJwks.baseUrl === baseUrl && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
|
|
199
321
|
return cachedJwks.jwks;
|
|
200
322
|
}
|
|
323
|
+
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
324
|
+
const jwksUri = discovery?.jwks_uri || `${baseUrl}/api/oauth/jwks`;
|
|
201
325
|
try {
|
|
202
|
-
const res = await fetch(
|
|
326
|
+
const res = await fetch(jwksUri, options.forceRefresh ? { cache: "no-cache" } : void 0);
|
|
203
327
|
if (!res.ok) return null;
|
|
204
328
|
const jwks = await res.json();
|
|
205
329
|
cachedJwks = { baseUrl, jwks, fetchedAt: now };
|
|
@@ -208,24 +332,13 @@ async function fetchJwks(baseUrl) {
|
|
|
208
332
|
return null;
|
|
209
333
|
}
|
|
210
334
|
}
|
|
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) {
|
|
335
|
+
async function verifyRs256Signature(token, jwk) {
|
|
225
336
|
try {
|
|
226
337
|
const [headerB64, payloadB64, signature] = token.split(".");
|
|
227
338
|
if (!signature || !jwk.n || !jwk.e) return false;
|
|
228
|
-
const
|
|
339
|
+
const c = getCrypto();
|
|
340
|
+
if (!c) return false;
|
|
341
|
+
const cryptoKey = await c.subtle.importKey(
|
|
229
342
|
"jwk",
|
|
230
343
|
{ kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: false },
|
|
231
344
|
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
@@ -234,7 +347,7 @@ async function verifyRs256(token, jwk) {
|
|
|
234
347
|
);
|
|
235
348
|
const signatureBytes = base64UrlDecode(signature);
|
|
236
349
|
const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
|
|
237
|
-
return await
|
|
350
|
+
return await c.subtle.verify(
|
|
238
351
|
"RSASSA-PKCS1-v1_5",
|
|
239
352
|
cryptoKey,
|
|
240
353
|
signatureBytes,
|
|
@@ -244,7 +357,20 @@ async function verifyRs256(token, jwk) {
|
|
|
244
357
|
return false;
|
|
245
358
|
}
|
|
246
359
|
}
|
|
247
|
-
async function
|
|
360
|
+
async function computeAtHash(accessToken) {
|
|
361
|
+
const c = getCrypto();
|
|
362
|
+
if (!c) return "";
|
|
363
|
+
const hash = await c.subtle.digest("SHA-256", new TextEncoder().encode(accessToken));
|
|
364
|
+
const bytes = new Uint8Array(hash);
|
|
365
|
+
const half = bytes.slice(0, bytes.length / 2);
|
|
366
|
+
let binary = "";
|
|
367
|
+
for (const b of half) {
|
|
368
|
+
binary += String.fromCharCode(b);
|
|
369
|
+
}
|
|
370
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
371
|
+
}
|
|
372
|
+
async function validateIdToken(idToken, accessToken, expectedIssuer, expectedClientId, options = {}) {
|
|
373
|
+
const { rejectHs256WhenRs256Available = true } = options;
|
|
248
374
|
const header = decodeJwtHeader(idToken);
|
|
249
375
|
if (!header) {
|
|
250
376
|
throw new SsoError("id_token_invalid", "ID Token \u683C\u5F0F\u9519\u8BEF");
|
|
@@ -253,54 +379,91 @@ async function validateIdToken(idToken, expectedIssuer, expectedClientId, access
|
|
|
253
379
|
if (typeof alg !== "string" || alg !== "RS256" && alg !== "HS256") {
|
|
254
380
|
throw new SsoError("id_token_unsupported_alg", `\u4E0D\u652F\u6301\u7684 ID Token \u7B7E\u540D\u7B97\u6CD5: ${alg}`);
|
|
255
381
|
}
|
|
382
|
+
const baseUrl = normalizeIssuer(expectedIssuer);
|
|
383
|
+
const discovery = await fetchDiscoveryDoc(baseUrl);
|
|
384
|
+
const normalizedIssuer = normalizeIssuer(discovery?.issuer || baseUrl);
|
|
256
385
|
if (alg === "RS256") {
|
|
257
|
-
const jwks = await fetchJwks(
|
|
386
|
+
const jwks = await fetchJwks(baseUrl);
|
|
258
387
|
if (!jwks) {
|
|
259
388
|
throw new SsoError("id_token_invalid_signature", "\u65E0\u6CD5\u83B7\u53D6 JWKS \u9A8C\u8BC1 ID Token \u7B7E\u540D");
|
|
260
389
|
}
|
|
261
390
|
const kid = typeof header.kid === "string" ? header.kid : void 0;
|
|
262
|
-
const
|
|
391
|
+
const matchCandidates = (set) => set.keys.filter(
|
|
263
392
|
(k) => k.kty === "RSA" && k.alg === "RS256" && k.use === "sig" && (kid ? k.kid === kid : true)
|
|
264
393
|
);
|
|
265
|
-
|
|
394
|
+
const verifyAny = async (keys) => {
|
|
395
|
+
for (const key of keys) {
|
|
396
|
+
if (await verifyRs256Signature(idToken, key)) return true;
|
|
397
|
+
}
|
|
398
|
+
return false;
|
|
399
|
+
};
|
|
400
|
+
let candidates = matchCandidates(jwks);
|
|
401
|
+
let validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
|
|
402
|
+
if (candidates.length === 0 || !validSig) {
|
|
403
|
+
const freshJwks = await fetchJwks(baseUrl, { forceRefresh: true });
|
|
404
|
+
if (freshJwks) {
|
|
405
|
+
candidates = matchCandidates(freshJwks);
|
|
406
|
+
validSig = candidates.length > 0 ? await verifyAny(candidates) : false;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (candidates.length === 0) {
|
|
266
410
|
throw new SsoError("id_token_invalid_signature", "JWKS \u4E2D\u672A\u627E\u5230\u5339\u914D\u7684 RS256 \u516C\u94A5");
|
|
267
411
|
}
|
|
268
|
-
const validSig = await verifyRs256(idToken, key);
|
|
269
412
|
if (!validSig) {
|
|
270
413
|
throw new SsoError("id_token_invalid_signature", "ID Token \u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25");
|
|
271
414
|
}
|
|
272
415
|
}
|
|
273
416
|
if (alg === "HS256") {
|
|
274
|
-
|
|
275
|
-
|
|
417
|
+
if (rejectHs256WhenRs256Available) {
|
|
418
|
+
const jwks = await fetchJwks(baseUrl);
|
|
419
|
+
const hasRs256 = jwks?.keys?.some((k) => k.alg === "RS256" && k.use === "sig");
|
|
420
|
+
if (hasRs256) {
|
|
421
|
+
throw new SsoError("id_token_unsupported_alg", "SSO \u4E2D\u5FC3\u5DF2\u914D\u7F6E RS256\uFF0C\u62D2\u7EDD HS256 ID Token");
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
throw new SsoError(
|
|
425
|
+
"id_token_hs256_unsupported",
|
|
426
|
+
"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
427
|
);
|
|
277
428
|
}
|
|
278
429
|
const payload = decodeJwtPayload(idToken);
|
|
279
430
|
if (!payload) {
|
|
280
431
|
throw new SsoError("id_token_invalid", "ID Token payload \u89E3\u6790\u5931\u8D25");
|
|
281
432
|
}
|
|
282
|
-
const normalizedExpectedIssuer = normalizeIssuer(expectedIssuer);
|
|
283
433
|
const tokenIssuer = typeof payload.iss === "string" ? normalizeIssuer(payload.iss) : "";
|
|
284
|
-
if (tokenIssuer !==
|
|
434
|
+
if (tokenIssuer !== normalizedIssuer) {
|
|
285
435
|
throw new SsoError("id_token_issuer_mismatch", "ID Token issuer \u4E0D\u5339\u914D");
|
|
286
436
|
}
|
|
287
|
-
|
|
437
|
+
const aud = payload.aud;
|
|
438
|
+
const audList = Array.isArray(aud) ? aud : typeof aud === "string" ? [aud] : [];
|
|
439
|
+
if (!audList.includes(expectedClientId)) {
|
|
288
440
|
throw new SsoError("id_token_audience_mismatch", "ID Token audience \u4E0D\u5339\u914D");
|
|
289
441
|
}
|
|
290
|
-
if (
|
|
442
|
+
if (audList.length > 1 && payload.azp !== expectedClientId) {
|
|
443
|
+
throw new SsoError("id_token_audience_mismatch", "ID Token \u591A audience \u65F6 azp \u5FC5\u987B\u4E3A\u5F53\u524D client");
|
|
444
|
+
}
|
|
445
|
+
if (typeof payload.exp !== "number") {
|
|
446
|
+
throw new SsoError("id_token_invalid", "ID Token \u7F3A\u5C11 exp \u58F0\u660E");
|
|
447
|
+
}
|
|
448
|
+
if (Date.now() >= payload.exp * 1e3) {
|
|
291
449
|
throw new SsoError("id_token_expired", "ID Token \u5DF2\u8FC7\u671F");
|
|
292
450
|
}
|
|
451
|
+
if (typeof payload.iat === "number" && payload.iat * 1e3 > Date.now() + 6e4) {
|
|
452
|
+
throw new SsoError("id_token_invalid", "ID Token iat \u5728\u672A\u6765\uFF0C\u7591\u4F3C\u4F2A\u9020\u6216\u65F6\u949F\u5F02\u5E38");
|
|
453
|
+
}
|
|
293
454
|
if (typeof payload.sub !== "string" || !payload.sub) {
|
|
294
455
|
throw new SsoError("id_token_missing_sub", "ID Token \u7F3A\u5C11 sub");
|
|
295
456
|
}
|
|
296
457
|
if (typeof payload.at_hash === "string" && payload.at_hash) {
|
|
297
458
|
const actual = await computeAtHash(accessToken);
|
|
298
|
-
if (actual
|
|
459
|
+
if (!timingSafeEqualString(actual, payload.at_hash)) {
|
|
299
460
|
throw new SsoError("id_token_at_hash_mismatch", "ID Token at_hash \u4E0D\u5339\u914D");
|
|
300
461
|
}
|
|
301
462
|
}
|
|
302
463
|
return { sub: payload.sub };
|
|
303
464
|
}
|
|
465
|
+
|
|
466
|
+
// src/core/SsoClient.ts
|
|
304
467
|
var _SsoClient = class _SsoClient {
|
|
305
468
|
constructor(config) {
|
|
306
469
|
this._discovery = null;
|
|
@@ -369,6 +532,17 @@ var _SsoClient = class _SsoClient {
|
|
|
369
532
|
if (d) return d.userinfo_endpoint;
|
|
370
533
|
return `${this.config.ssoBaseUrl}/api/oauth/userinfo`;
|
|
371
534
|
}
|
|
535
|
+
/**
|
|
536
|
+
* 开放重定向防护:仅保存相对路径或与当前页面同源的 returnUrl,
|
|
537
|
+
* 其余(如 https://evil.com)忽略并告警,防止回调后跳转到钓鱼站点。
|
|
538
|
+
*/
|
|
539
|
+
_saveReturnUrlIfTrusted(returnUrl) {
|
|
540
|
+
if (isTrustedReturnUrl(returnUrl, window.location.origin)) {
|
|
541
|
+
saveReturnUrl(returnUrl);
|
|
542
|
+
} else {
|
|
543
|
+
console.warn(`[SSO SDK] returnUrl \u672A\u901A\u8FC7\u540C\u6E90\u6821\u9A8C\uFF0C\u5DF2\u5FFD\u7565: ${returnUrl}`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
372
546
|
// ============================================
|
|
373
547
|
// 公共 API
|
|
374
548
|
// ============================================
|
|
@@ -378,7 +552,8 @@ var _SsoClient = class _SsoClient {
|
|
|
378
552
|
* 生成 PKCE code_verifier/code_challenge 和 state 参数,
|
|
379
553
|
* 构建 authorize URL,通过 302 跳转到 SSO 登录页。
|
|
380
554
|
*
|
|
381
|
-
* @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage
|
|
555
|
+
* @param returnUrl - 登录成功后的返回地址(可选,保存到 sessionStorage;
|
|
556
|
+
* 仅允许相对路径或同源绝对 URL,否则忽略并告警)
|
|
382
557
|
*/
|
|
383
558
|
async login(returnUrl) {
|
|
384
559
|
const verifier = generateCodeVerifier();
|
|
@@ -387,7 +562,7 @@ var _SsoClient = class _SsoClient {
|
|
|
387
562
|
savePkceVerifier(this.config.clientId, verifier);
|
|
388
563
|
saveOAuthState(state, this.config.clientId);
|
|
389
564
|
if (returnUrl) {
|
|
390
|
-
|
|
565
|
+
this._saveReturnUrlIfTrusted(returnUrl);
|
|
391
566
|
}
|
|
392
567
|
const authorizeEndpoint = await this._getAuthorizeEndpoint();
|
|
393
568
|
const params = new URLSearchParams();
|
|
@@ -411,7 +586,7 @@ var _SsoClient = class _SsoClient {
|
|
|
411
586
|
const state = generateState();
|
|
412
587
|
savePkceVerifier(this.config.clientId, verifier);
|
|
413
588
|
saveOAuthState(state, this.config.clientId);
|
|
414
|
-
if (returnUrl)
|
|
589
|
+
if (returnUrl) this._saveReturnUrlIfTrusted(returnUrl);
|
|
415
590
|
const authorizeEndpoint = await this._getAuthorizeEndpoint();
|
|
416
591
|
const params = new URLSearchParams();
|
|
417
592
|
params.set("response_type", "code");
|
|
@@ -423,11 +598,140 @@ var _SsoClient = class _SsoClient {
|
|
|
423
598
|
params.set("code_challenge_method", "S256");
|
|
424
599
|
return `${authorizeEndpoint}?${params.toString()}`;
|
|
425
600
|
}
|
|
601
|
+
/**
|
|
602
|
+
* 弹窗模式 SSO 登录
|
|
603
|
+
*
|
|
604
|
+
* 打开一个小窗口进行登录,认证完成后窗口自动关闭,
|
|
605
|
+
* 主页面不丢失状态(适用于 SPA 中需要保持表单/浏览上下文的场景)。
|
|
606
|
+
*
|
|
607
|
+
* 流程:
|
|
608
|
+
* 1. window.open() 打开授权 URL 到弹窗
|
|
609
|
+
* 2. 用户在弹窗中完成登录
|
|
610
|
+
* 3. 弹窗加载 CallbackPage 时检测到 window.opener,通过 postMessage 回传回调 URL
|
|
611
|
+
* 4. 主页面收到消息后调用 handleCallback() 交换 token
|
|
612
|
+
* 5. 弹窗自动关闭
|
|
613
|
+
*
|
|
614
|
+
* @param options - 弹窗配置
|
|
615
|
+
* @param options.returnUrl - 登录成功后的返回地址
|
|
616
|
+
* @param options.width - 弹窗宽度(默认 480)
|
|
617
|
+
* @param options.height - 弹窗高度(默认 640)
|
|
618
|
+
* @returns TokenData
|
|
619
|
+
*
|
|
620
|
+
* @example
|
|
621
|
+
* ```typescript
|
|
622
|
+
* const client = new SsoClient({ clientId: "xxx", redirectUri: "https://myapp.com/callback", ssoBaseUrl: "https://nihplod.cn" });
|
|
623
|
+
* try {
|
|
624
|
+
* const tokenData = await client.loginPopup({ returnUrl: "/dashboard" });
|
|
625
|
+
* console.log("登录成功", tokenData);
|
|
626
|
+
* } catch (err) {
|
|
627
|
+
* if (err instanceof SsoError && err.code === "popup_blocked") {
|
|
628
|
+
* // 弹窗被拦截,回退到同页重定向
|
|
629
|
+
* await client.login("/dashboard");
|
|
630
|
+
* }
|
|
631
|
+
* }
|
|
632
|
+
* ```
|
|
633
|
+
*/
|
|
634
|
+
async loginPopup(options = {}) {
|
|
635
|
+
const verifier = generateCodeVerifier();
|
|
636
|
+
const challenge = await generateCodeChallenge(verifier);
|
|
637
|
+
const state = generateState();
|
|
638
|
+
const popupNonce = generateState();
|
|
639
|
+
savePkceVerifier(this.config.clientId, verifier);
|
|
640
|
+
saveOAuthState(state, this.config.clientId);
|
|
641
|
+
savePkceVerifier(`${this.config.clientId}_popup_nonce`, popupNonce);
|
|
642
|
+
if (options.returnUrl) {
|
|
643
|
+
this._saveReturnUrlIfTrusted(options.returnUrl);
|
|
644
|
+
}
|
|
645
|
+
const authorizeEndpoint = await this._getAuthorizeEndpoint();
|
|
646
|
+
const params = new URLSearchParams();
|
|
647
|
+
params.set("response_type", "code");
|
|
648
|
+
params.set("client_id", this.config.clientId);
|
|
649
|
+
params.set("redirect_uri", this.config.redirectUri);
|
|
650
|
+
params.set("scope", this.config.scopes || "openid profile");
|
|
651
|
+
params.set("state", state);
|
|
652
|
+
params.set("code_challenge", challenge);
|
|
653
|
+
params.set("code_challenge_method", "S256");
|
|
654
|
+
params.set("popup_nonce", popupNonce);
|
|
655
|
+
const width = options.width || 480;
|
|
656
|
+
const height = options.height || 640;
|
|
657
|
+
const left = Math.max(0, (window.screen.width - width) / 2);
|
|
658
|
+
const top = Math.max(0, (window.screen.height - height) / 2);
|
|
659
|
+
const popupFeatures = [
|
|
660
|
+
`width=${width}`,
|
|
661
|
+
`height=${height}`,
|
|
662
|
+
`left=${Math.round(left)}`,
|
|
663
|
+
`top=${Math.round(top)}`,
|
|
664
|
+
"resizable=yes",
|
|
665
|
+
"scrollbars=yes",
|
|
666
|
+
"status=yes"
|
|
667
|
+
].join(",");
|
|
668
|
+
const popup = window.open(
|
|
669
|
+
`${authorizeEndpoint}?${params.toString()}`,
|
|
670
|
+
"nihplod_sso_popup",
|
|
671
|
+
popupFeatures
|
|
672
|
+
);
|
|
673
|
+
if (!popup) {
|
|
674
|
+
removePkceVerifier(this.config.clientId);
|
|
675
|
+
removeOAuthState(this.config.clientId);
|
|
676
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
677
|
+
throw new SsoError(
|
|
678
|
+
"popup_blocked",
|
|
679
|
+
"\u5F39\u7A97\u88AB\u6D4F\u89C8\u5668\u62E6\u622A\uFF0C\u8BF7\u5141\u8BB8\u5F39\u7A97\u540E\u91CD\u8BD5"
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
const redirectUriOrigin = new URL(this.config.redirectUri).origin;
|
|
683
|
+
try {
|
|
684
|
+
popup.focus();
|
|
685
|
+
return await new Promise((resolve, reject) => {
|
|
686
|
+
let completed = false;
|
|
687
|
+
const handleMessage = (event) => {
|
|
688
|
+
if (completed) return;
|
|
689
|
+
if (event.origin !== redirectUriOrigin) return;
|
|
690
|
+
if (!event.data || event.data.type !== "nihplod_sso_popup_callback") return;
|
|
691
|
+
if (!event.data.callbackUrl) return;
|
|
692
|
+
const savedNonce = getPkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
693
|
+
if (!savedNonce || !timingSafeEqualString(event.data.nonce ?? "", savedNonce)) return;
|
|
694
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
695
|
+
completed = true;
|
|
696
|
+
cleanup();
|
|
697
|
+
if (popup && !popup.closed) {
|
|
698
|
+
popup.close();
|
|
699
|
+
}
|
|
700
|
+
this.handleCallback(event.data.callbackUrl).then(resolve).catch(reject);
|
|
701
|
+
};
|
|
702
|
+
const pollTimer = setInterval(() => {
|
|
703
|
+
if (popup.closed) {
|
|
704
|
+
if (!completed) {
|
|
705
|
+
completed = true;
|
|
706
|
+
cleanup();
|
|
707
|
+
reject(
|
|
708
|
+
new SsoError(
|
|
709
|
+
"popup_closed",
|
|
710
|
+
"\u767B\u5F55\u7A97\u53E3\u5DF2\u5173\u95ED"
|
|
711
|
+
)
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}, 500);
|
|
716
|
+
const cleanup = () => {
|
|
717
|
+
clearInterval(pollTimer);
|
|
718
|
+
window.removeEventListener("message", handleMessage);
|
|
719
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
720
|
+
};
|
|
721
|
+
window.addEventListener("message", handleMessage);
|
|
722
|
+
});
|
|
723
|
+
} catch (err) {
|
|
724
|
+
removePkceVerifier(this.config.clientId);
|
|
725
|
+
removeOAuthState(this.config.clientId);
|
|
726
|
+
removePkceVerifier(`${this.config.clientId}_popup_nonce`);
|
|
727
|
+
throw err;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
426
730
|
/**
|
|
427
731
|
* 处理 OAuth 回调
|
|
428
732
|
*
|
|
429
733
|
* 解析回调 URL,校验 state 参数,用授权码交换 token。
|
|
430
|
-
* 成功后 token 自动保存到
|
|
734
|
+
* 成功后 token 自动保存到 token 存储(默认内存,可通过 setTokenStorage 定制)。
|
|
431
735
|
*
|
|
432
736
|
* @param callbackUrl - 完整的回调 URL(window.location.href)
|
|
433
737
|
* @returns TokenData 或 null
|
|
@@ -446,14 +750,14 @@ var _SsoClient = class _SsoClient {
|
|
|
446
750
|
throw new SsoError("token_request_failed", "\u56DE\u8C03 URL \u4E2D\u7F3A\u5C11 authorization code");
|
|
447
751
|
}
|
|
448
752
|
const savedState = getOAuthState(this.config.clientId);
|
|
449
|
-
if (!savedState || savedState
|
|
753
|
+
if (!savedState || !timingSafeEqualString(savedState, returnedState ?? "")) {
|
|
450
754
|
removeOAuthState(this.config.clientId);
|
|
755
|
+
removePkceVerifier(this.config.clientId);
|
|
451
756
|
throw new SsoError(
|
|
452
757
|
"state_mismatch",
|
|
453
758
|
"State \u53C2\u6570\u4E0D\u5339\u914D\uFF0C\u53EF\u80FD\u5B58\u5728 CSRF \u653B\u51FB"
|
|
454
759
|
);
|
|
455
760
|
}
|
|
456
|
-
removeOAuthState(this.config.clientId);
|
|
457
761
|
const verifier = getPkceVerifier(this.config.clientId);
|
|
458
762
|
if (!verifier) {
|
|
459
763
|
throw new SsoError(
|
|
@@ -461,7 +765,6 @@ var _SsoClient = class _SsoClient {
|
|
|
461
765
|
"code_verifier \u4E0D\u5B58\u5728\uFF08\u53EF\u80FD\u5DF2\u8FC7\u671F\u6216\u6765\u81EA\u5176\u4ED6\u6807\u7B7E\u9875\uFF09"
|
|
462
766
|
);
|
|
463
767
|
}
|
|
464
|
-
removePkceVerifier(this.config.clientId);
|
|
465
768
|
const tokenEndpoint = await this._getTokenEndpoint();
|
|
466
769
|
const body = new URLSearchParams();
|
|
467
770
|
body.set("grant_type", "authorization_code");
|
|
@@ -488,19 +791,22 @@ var _SsoClient = class _SsoClient {
|
|
|
488
791
|
errData = await res.json();
|
|
489
792
|
} catch {
|
|
490
793
|
}
|
|
794
|
+
const serverError = errData.error || "";
|
|
491
795
|
throw new SsoError(
|
|
492
|
-
|
|
796
|
+
mapOAuthErrorToSsoCode(serverError),
|
|
493
797
|
errData.error_description || `Token \u8BF7\u6C42\u5931\u8D25: HTTP ${res.status}`
|
|
494
798
|
);
|
|
495
799
|
}
|
|
496
800
|
const data = await res.json();
|
|
801
|
+
removeOAuthState(this.config.clientId);
|
|
802
|
+
removePkceVerifier(this.config.clientId);
|
|
497
803
|
if (data.id_token) {
|
|
498
804
|
try {
|
|
499
805
|
await validateIdToken(
|
|
500
806
|
data.id_token,
|
|
807
|
+
data.access_token,
|
|
501
808
|
this.config.ssoBaseUrl,
|
|
502
|
-
this.config.clientId
|
|
503
|
-
data.access_token
|
|
809
|
+
this.config.clientId
|
|
504
810
|
);
|
|
505
811
|
} catch (err) {
|
|
506
812
|
removeTokenData(this.config.clientId);
|
|
@@ -549,15 +855,24 @@ var _SsoClient = class _SsoClient {
|
|
|
549
855
|
if (this.config.clientSecret) {
|
|
550
856
|
body.set("client_secret", this.config.clientSecret);
|
|
551
857
|
}
|
|
552
|
-
let res;
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
858
|
+
let res = null;
|
|
859
|
+
let lastErr;
|
|
860
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
861
|
+
try {
|
|
862
|
+
res = await fetch(tokenEndpoint, {
|
|
863
|
+
method: "POST",
|
|
864
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
865
|
+
body: body.toString()
|
|
866
|
+
});
|
|
867
|
+
lastErr = null;
|
|
868
|
+
break;
|
|
869
|
+
} catch (err) {
|
|
870
|
+
lastErr = err;
|
|
871
|
+
if (attempt < 1) await new Promise((r) => setTimeout(r, 1e3));
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
if (lastErr || !res) {
|
|
875
|
+
throw new SsoError("network_error", "\u5237\u65B0 Token \u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25", lastErr);
|
|
561
876
|
}
|
|
562
877
|
if (!res.ok) {
|
|
563
878
|
let errData = {};
|
|
@@ -570,17 +885,31 @@ var _SsoClient = class _SsoClient {
|
|
|
570
885
|
removeTokenData(this.config.clientId);
|
|
571
886
|
}
|
|
572
887
|
throw new SsoError(
|
|
573
|
-
errorCode
|
|
888
|
+
mapOAuthErrorToSsoCode(errorCode, "refresh"),
|
|
574
889
|
errData.error_description || `\u5237\u65B0 Token \u5931\u8D25: HTTP ${res.status}`
|
|
575
890
|
);
|
|
576
891
|
}
|
|
577
892
|
const data = await res.json();
|
|
893
|
+
if (data.id_token) {
|
|
894
|
+
try {
|
|
895
|
+
await validateIdToken(
|
|
896
|
+
data.id_token,
|
|
897
|
+
data.access_token,
|
|
898
|
+
this.config.ssoBaseUrl,
|
|
899
|
+
this.config.clientId
|
|
900
|
+
);
|
|
901
|
+
} catch (err) {
|
|
902
|
+
removeTokenData(this.config.clientId);
|
|
903
|
+
throw err;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
578
906
|
const now = Date.now();
|
|
579
907
|
const tokenData = {
|
|
580
908
|
access_token: data.access_token,
|
|
581
909
|
token_type: data.token_type,
|
|
582
910
|
expires_in: data.expires_in,
|
|
583
|
-
|
|
911
|
+
// RFC 6749 §6:刷新响应可省略 refresh_token,此时沿用旧值
|
|
912
|
+
refresh_token: data.refresh_token || current.refresh_token,
|
|
584
913
|
id_token: data.id_token,
|
|
585
914
|
issued_at: now,
|
|
586
915
|
expires_at: now + data.expires_in * 1e3
|
|
@@ -681,13 +1010,14 @@ var _SsoClient = class _SsoClient {
|
|
|
681
1010
|
}
|
|
682
1011
|
if (redirectToSso) {
|
|
683
1012
|
const discovery = await this._getDiscovery();
|
|
684
|
-
const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/
|
|
1013
|
+
const endSessionEndpoint = discovery?.end_session_endpoint || `${this.config.ssoBaseUrl}/api/oauth/end-session`;
|
|
685
1014
|
const logoutUrl = new URL(endSessionEndpoint);
|
|
686
1015
|
logoutUrl.searchParams.set("client_id", this.config.clientId);
|
|
687
|
-
|
|
1016
|
+
const postLogoutUri = this.config.postLogoutRedirectUri || this.config.redirectUri;
|
|
1017
|
+
if (postLogoutUri) {
|
|
688
1018
|
logoutUrl.searchParams.set(
|
|
689
1019
|
"post_logout_redirect_uri",
|
|
690
|
-
|
|
1020
|
+
postLogoutUri
|
|
691
1021
|
);
|
|
692
1022
|
}
|
|
693
1023
|
if (idTokenHint) {
|
|
@@ -719,6 +1049,7 @@ export {
|
|
|
719
1049
|
SsoError,
|
|
720
1050
|
clearAllSsoData,
|
|
721
1051
|
clearVerifiersForClients,
|
|
1052
|
+
createSecureStorage,
|
|
722
1053
|
generateCodeChallenge,
|
|
723
1054
|
generateCodeVerifier,
|
|
724
1055
|
generateState,
|
|
@@ -727,6 +1058,8 @@ export {
|
|
|
727
1058
|
getReturnUrl,
|
|
728
1059
|
getTokenData,
|
|
729
1060
|
getTokenStorage,
|
|
1061
|
+
isTrustedReturnUrl,
|
|
1062
|
+
mapOAuthErrorToSsoCode,
|
|
730
1063
|
removeOAuthState,
|
|
731
1064
|
removePkceVerifier,
|
|
732
1065
|
removeReturnUrl,
|
|
@@ -735,6 +1068,7 @@ export {
|
|
|
735
1068
|
savePkceVerifier,
|
|
736
1069
|
saveReturnUrl,
|
|
737
1070
|
saveTokenData,
|
|
738
|
-
setTokenStorage
|
|
1071
|
+
setTokenStorage,
|
|
1072
|
+
timingSafeEqualString
|
|
739
1073
|
};
|
|
740
1074
|
//# sourceMappingURL=index.mjs.map
|