@svadmin/sso 0.8.8 → 0.9.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/package.json +2 -2
- package/src/auth-provider.ts +78 -24
- package/src/index.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svadmin/sso",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OIDC/OAuth2 SSO AuthProvider plugin for svadmin — supports Okta, Azure AD, Cognito, Keycloak, Google",
|
|
6
6
|
"license": "MIT",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"!src/**/setupTest.*"
|
|
36
36
|
],
|
|
37
37
|
"peerDependencies": {
|
|
38
|
-
"@svadmin/core": "^0.25.
|
|
38
|
+
"@svadmin/core": "^0.25.4"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^5.7.0"
|
package/src/auth-provider.ts
CHANGED
|
@@ -32,14 +32,14 @@
|
|
|
32
32
|
import type { AuthProvider, Identity } from '@svadmin/core';
|
|
33
33
|
|
|
34
34
|
// Safe base64url decode for JWT payloads
|
|
35
|
-
function decodeJwtPayload(token: string):
|
|
35
|
+
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
|
36
36
|
if (!token) return null;
|
|
37
37
|
const parts = token.split('.');
|
|
38
38
|
if (parts.length < 2) return null;
|
|
39
39
|
const payloadStr = parts[1];
|
|
40
40
|
const padded = payloadStr + '='.repeat((4 - (payloadStr.length % 4)) % 4);
|
|
41
41
|
const binString = atob(padded.replace(/-/g, '+').replace(/_/g, '/'));
|
|
42
|
-
const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0)
|
|
42
|
+
const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0) ?? 0);
|
|
43
43
|
return JSON.parse(new TextDecoder().decode(bytes));
|
|
44
44
|
}
|
|
45
45
|
|
|
@@ -65,6 +65,8 @@ export interface SSOConfig {
|
|
|
65
65
|
autoRefresh?: boolean;
|
|
66
66
|
/** Seconds before expiry to trigger refresh. Default: 60 */
|
|
67
67
|
refreshBuffer?: number;
|
|
68
|
+
/** Additional authorization request parameters, e.g. audience or prompt. */
|
|
69
|
+
authorizationParams?: Record<string, string>;
|
|
68
70
|
/**
|
|
69
71
|
* Manual OAuth2 endpoints for providers that don't support OIDC discovery
|
|
70
72
|
* (e.g., GitHub). When provided, OIDC auto-discovery is skipped.
|
|
@@ -83,6 +85,10 @@ export interface TokenStorage {
|
|
|
83
85
|
removeItem: (key: string) => void;
|
|
84
86
|
}
|
|
85
87
|
|
|
88
|
+
export interface SSOAuthProvider extends AuthProvider {
|
|
89
|
+
getAccessToken: () => Promise<string | null>;
|
|
90
|
+
}
|
|
91
|
+
|
|
86
92
|
interface OIDCConfig {
|
|
87
93
|
authorization_endpoint: string;
|
|
88
94
|
token_endpoint: string;
|
|
@@ -143,7 +149,7 @@ function getStorage(config: SSOConfig): TokenStorage {
|
|
|
143
149
|
/**
|
|
144
150
|
* Create an OIDC/OAuth2 SSO AuthProvider.
|
|
145
151
|
*/
|
|
146
|
-
export function createSSOAuthProvider(config: SSOConfig):
|
|
152
|
+
export function createSSOAuthProvider(config: SSOConfig): SSOAuthProvider {
|
|
147
153
|
const scopes = config.scopes ?? ['openid', 'profile', 'email'];
|
|
148
154
|
const autoRefresh = config.autoRefresh ?? true;
|
|
149
155
|
const refreshBuffer = config.refreshBuffer ?? 60;
|
|
@@ -198,7 +204,8 @@ export function createSSOAuthProvider(config: SSOConfig): AuthProvider {
|
|
|
198
204
|
if (!tokens.expires_at || !tokens.refresh_token) return;
|
|
199
205
|
const now = Math.floor(Date.now() / 1000);
|
|
200
206
|
const delay = Math.max(0, (tokens.expires_at - now - refreshBuffer)) * 1000;
|
|
201
|
-
|
|
207
|
+
const refreshToken = tokens.refresh_token;
|
|
208
|
+
refreshTimer = setTimeout(() => refreshAccessToken(refreshToken), delay);
|
|
202
209
|
}
|
|
203
210
|
|
|
204
211
|
async function refreshAccessToken(refreshToken: string): Promise<void> {
|
|
@@ -216,6 +223,10 @@ export function createSSOAuthProvider(config: SSOConfig): AuthProvider {
|
|
|
216
223
|
});
|
|
217
224
|
if (!res.ok) { clearTokens(); return; }
|
|
218
225
|
const data = await res.json();
|
|
226
|
+
if (!data.access_token) {
|
|
227
|
+
clearTokens();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
219
230
|
const newTokens: TokenSet = {
|
|
220
231
|
access_token: data.access_token,
|
|
221
232
|
id_token: data.id_token,
|
|
@@ -248,6 +259,7 @@ export function createSSOAuthProvider(config: SSOConfig): AuthProvider {
|
|
|
248
259
|
});
|
|
249
260
|
if (!res.ok) throw new Error(`Token exchange failed: ${res.status}`);
|
|
250
261
|
const data = await res.json();
|
|
262
|
+
if (!data.access_token) throw new Error('Token exchange failed: missing access_token');
|
|
251
263
|
const tokens: TokenSet = {
|
|
252
264
|
access_token: data.access_token,
|
|
253
265
|
id_token: data.id_token,
|
|
@@ -281,28 +293,33 @@ export function createSSOAuthProvider(config: SSOConfig): AuthProvider {
|
|
|
281
293
|
if (typeof window === 'undefined') {
|
|
282
294
|
return { success: false, error: { message: 'SSO login requires a browser environment' } };
|
|
283
295
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
296
|
+
try {
|
|
297
|
+
const cfg = await discover();
|
|
298
|
+
const { verifier, challenge } = await createPKCEChallenge();
|
|
299
|
+
const state = generateRandomString(32);
|
|
287
300
|
|
|
288
|
-
|
|
289
|
-
|
|
301
|
+
storage.setItem(`${STORAGE_PREFIX}pkce_verifier`, verifier);
|
|
302
|
+
storage.setItem(`${STORAGE_PREFIX}state`, state);
|
|
290
303
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
304
|
+
const params = new URLSearchParams({
|
|
305
|
+
response_type: 'code',
|
|
306
|
+
client_id: config.clientId,
|
|
307
|
+
redirect_uri: config.redirectUri,
|
|
308
|
+
scope: scopes.join(' '),
|
|
309
|
+
state,
|
|
310
|
+
code_challenge: challenge,
|
|
311
|
+
code_challenge_method: 'S256',
|
|
312
|
+
...config.authorizationParams,
|
|
313
|
+
});
|
|
300
314
|
|
|
301
|
-
|
|
302
|
-
|
|
315
|
+
// Redirect to authorization endpoint
|
|
316
|
+
window.location.href = `${cfg.authorization_endpoint}?${params}`;
|
|
303
317
|
|
|
304
|
-
|
|
305
|
-
|
|
318
|
+
// Won't actually reach here due to redirect
|
|
319
|
+
return { success: true };
|
|
320
|
+
} catch (err) {
|
|
321
|
+
return { success: false, error: { message: err instanceof Error ? err.message : 'SSO login failed' } };
|
|
322
|
+
}
|
|
306
323
|
},
|
|
307
324
|
|
|
308
325
|
async logout() {
|
|
@@ -339,12 +356,26 @@ export function createSSOAuthProvider(config: SSOConfig): AuthProvider {
|
|
|
339
356
|
|
|
340
357
|
// Handle callback — exchange authorization code for tokens
|
|
341
358
|
const url = new URL(window.location.href);
|
|
359
|
+
const callbackError = url.searchParams.get('error');
|
|
360
|
+
if (callbackError) {
|
|
361
|
+
clearTokens();
|
|
362
|
+
return {
|
|
363
|
+
authenticated: false,
|
|
364
|
+
error: {
|
|
365
|
+
message: url.searchParams.get('error_description') ?? callbackError,
|
|
366
|
+
name: callbackError,
|
|
367
|
+
},
|
|
368
|
+
logout: true,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
342
372
|
const code = url.searchParams.get('code');
|
|
343
373
|
const state = url.searchParams.get('state');
|
|
344
374
|
|
|
345
375
|
if (code) {
|
|
346
376
|
const savedState = storage.getItem(`${STORAGE_PREFIX}state`);
|
|
347
|
-
if (state
|
|
377
|
+
if (!state || !savedState || state !== savedState) {
|
|
378
|
+
clearTokens();
|
|
348
379
|
return { authenticated: false, error: { message: 'State mismatch' } };
|
|
349
380
|
}
|
|
350
381
|
try {
|
|
@@ -352,7 +383,7 @@ export function createSSOAuthProvider(config: SSOConfig): AuthProvider {
|
|
|
352
383
|
// Clean URL
|
|
353
384
|
url.searchParams.delete('code');
|
|
354
385
|
url.searchParams.delete('state');
|
|
355
|
-
window.history.replaceState({}, '', url.pathname);
|
|
386
|
+
window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`);
|
|
356
387
|
return { authenticated: true };
|
|
357
388
|
} catch (err) {
|
|
358
389
|
return { authenticated: false, error: { message: String(err) } };
|
|
@@ -409,6 +440,7 @@ export function createSSOAuthProvider(config: SSOConfig): AuthProvider {
|
|
|
409
440
|
if (tokens.id_token) {
|
|
410
441
|
try {
|
|
411
442
|
const payload = decodeJwtPayload(tokens.id_token);
|
|
443
|
+
if (!payload) return null;
|
|
412
444
|
return payload.roles ?? payload.groups ?? payload.permissions ?? null;
|
|
413
445
|
} catch {
|
|
414
446
|
return null;
|
|
@@ -418,6 +450,22 @@ export function createSSOAuthProvider(config: SSOConfig): AuthProvider {
|
|
|
418
450
|
return null;
|
|
419
451
|
},
|
|
420
452
|
|
|
453
|
+
async getAccessToken() {
|
|
454
|
+
const tokens = getTokens();
|
|
455
|
+
if (!tokens) return null;
|
|
456
|
+
|
|
457
|
+
if (tokens.expires_at && tokens.expires_at < Math.floor(Date.now() / 1000)) {
|
|
458
|
+
if (!tokens.refresh_token) {
|
|
459
|
+
clearTokens();
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
await refreshAccessToken(tokens.refresh_token);
|
|
463
|
+
return getTokens()?.access_token ?? null;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return tokens.access_token;
|
|
467
|
+
},
|
|
468
|
+
|
|
421
469
|
async onError(error) {
|
|
422
470
|
if (typeof error === 'object' && error !== null && 'statusCode' in error) {
|
|
423
471
|
const statusCode = (error as { statusCode: number }).statusCode;
|
|
@@ -425,6 +473,12 @@ export function createSSOAuthProvider(config: SSOConfig): AuthProvider {
|
|
|
425
473
|
return { logout: true, redirectTo: '/login' };
|
|
426
474
|
}
|
|
427
475
|
}
|
|
476
|
+
if (typeof error === 'object' && error !== null && 'status' in error) {
|
|
477
|
+
const status = (error as { status: number }).status;
|
|
478
|
+
if (status === 401 || status === 403) {
|
|
479
|
+
return { logout: true, redirectTo: '/login' };
|
|
480
|
+
}
|
|
481
|
+
}
|
|
428
482
|
return {};
|
|
429
483
|
},
|
|
430
484
|
};
|
package/src/index.ts
CHANGED