@svadmin/sso 0.8.7 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/sso",
3
- "version": "0.8.7",
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",
@@ -28,10 +28,14 @@
28
28
  "main": "./src/index.ts",
29
29
  "types": "./src/index.ts",
30
30
  "files": [
31
- "src"
31
+ "src/**/*.ts",
32
+ "!src/**/*.test.*",
33
+ "!src/**/*.spec.*",
34
+ "!src/**/vitest.config.*",
35
+ "!src/**/setupTest.*"
32
36
  ],
33
37
  "peerDependencies": {
34
- "@svadmin/core": "^0.23.3"
38
+ "@svadmin/core": "^0.25.4"
35
39
  },
36
40
  "devDependencies": {
37
41
  "typescript": "^5.7.0"
@@ -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): any {
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): AuthProvider {
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
- refreshTimer = setTimeout(() => refreshAccessToken(tokens.refresh_token!), delay);
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
- const cfg = await discover();
285
- const { verifier, challenge } = await createPKCEChallenge();
286
- const state = generateRandomString(32);
296
+ try {
297
+ const cfg = await discover();
298
+ const { verifier, challenge } = await createPKCEChallenge();
299
+ const state = generateRandomString(32);
287
300
 
288
- storage.setItem(`${STORAGE_PREFIX}pkce_verifier`, verifier);
289
- storage.setItem(`${STORAGE_PREFIX}state`, state);
301
+ storage.setItem(`${STORAGE_PREFIX}pkce_verifier`, verifier);
302
+ storage.setItem(`${STORAGE_PREFIX}state`, state);
290
303
 
291
- const params = new URLSearchParams({
292
- response_type: 'code',
293
- client_id: config.clientId,
294
- redirect_uri: config.redirectUri,
295
- scope: scopes.join(' '),
296
- state,
297
- code_challenge: challenge,
298
- code_challenge_method: 'S256',
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
- // Redirect to authorization endpoint
302
- window.location.href = `${cfg.authorization_endpoint}?${params}`;
315
+ // Redirect to authorization endpoint
316
+ window.location.href = `${cfg.authorization_endpoint}?${params}`;
303
317
 
304
- // Won't actually reach here due to redirect
305
- return { success: true };
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 && savedState && state !== savedState) {
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
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  export { createSSOAuthProvider } from './auth-provider';
11
- export type { SSOConfig, TokenStorage } from './auth-provider';
11
+ export type { SSOAuthProvider, SSOConfig, TokenStorage } from './auth-provider';
12
12
 
13
13
  // Social login presets
14
14
  export {
@@ -1,92 +0,0 @@
1
- /**
2
- * @svadmin/sso — Presets Unit Tests
3
- */
4
- import { describe, test, expect } from 'bun:test';
5
- import type { TokenStorage } from './auth-provider';
6
-
7
- // In-memory storage for tests (localStorage not available in Bun test)
8
- function createMemoryStorage(): TokenStorage {
9
- const store = new Map<string, string>();
10
- return {
11
- getItem: (key: string) => store.get(key) ?? null,
12
- setItem: (key: string, value: string) => { store.set(key, value); },
13
- removeItem: (key: string) => { store.delete(key); },
14
- };
15
- }
16
-
17
- describe('SSO presets', () => {
18
- // Import the module to verify it compiles correctly
19
- test('presets module exports all factories', async () => {
20
- const presets = await import('./presets');
21
- expect(typeof presets.createGoogleAuth).toBe('function');
22
- expect(typeof presets.createMicrosoftAuth).toBe('function');
23
- expect(typeof presets.createGitHubAuth).toBe('function');
24
- expect(typeof presets.createGitLabAuth).toBe('function');
25
- expect(typeof presets.createKeycloakAuth).toBe('function');
26
- expect(typeof presets.createAuth0Auth).toBe('function');
27
- });
28
-
29
- test('createGoogleAuth returns AuthProvider', async () => {
30
- const { createGoogleAuth } = await import('./presets');
31
- const provider = createGoogleAuth('test-client-id', {
32
- redirectUri: 'http://localhost/callback',
33
- storage: createMemoryStorage(),
34
- });
35
- expect(provider).toBeTruthy();
36
- expect(typeof provider.login).toBe('function');
37
- expect(typeof provider.logout).toBe('function');
38
- expect(typeof provider.check).toBe('function');
39
- expect(typeof provider.getIdentity).toBe('function');
40
- });
41
-
42
- test('createMicrosoftAuth accepts tenantId', async () => {
43
- const { createMicrosoftAuth } = await import('./presets');
44
- const provider = createMicrosoftAuth('test-id', 'my-tenant', {
45
- redirectUri: 'http://localhost/callback',
46
- storage: createMemoryStorage(),
47
- });
48
- expect(provider).toBeTruthy();
49
- expect(typeof provider.login).toBe('function');
50
- });
51
-
52
- test('createGitHubAuth returns AuthProvider', async () => {
53
- const { createGitHubAuth } = await import('./presets');
54
- const provider = createGitHubAuth('test-github-id', {
55
- redirectUri: 'http://localhost/callback',
56
- storage: createMemoryStorage(),
57
- });
58
- expect(provider).toBeTruthy();
59
- expect(typeof provider.login).toBe('function');
60
- expect(typeof provider.getIdentity).toBe('function');
61
- });
62
-
63
- test('createKeycloakAuth returns AuthProvider', async () => {
64
- const { createKeycloakAuth } = await import('./presets');
65
- const provider = createKeycloakAuth('https://kc.example.com', 'my-realm', 'client-id', {
66
- redirectUri: 'http://localhost/callback',
67
- storage: createMemoryStorage(),
68
- });
69
- expect(provider).toBeTruthy();
70
- expect(typeof provider.login).toBe('function');
71
- });
72
-
73
- test('createAuth0Auth returns AuthProvider', async () => {
74
- const { createAuth0Auth } = await import('./presets');
75
- const provider = createAuth0Auth('my-tenant.auth0.com', 'client-id', {
76
- redirectUri: 'http://localhost/callback',
77
- storage: createMemoryStorage(),
78
- });
79
- expect(provider).toBeTruthy();
80
- expect(typeof provider.login).toBe('function');
81
- });
82
-
83
- test('createGitLabAuth returns AuthProvider', async () => {
84
- const { createGitLabAuth } = await import('./presets');
85
- const provider = createGitLabAuth('test-id', 'https://gitlab.example.com', {
86
- redirectUri: 'http://localhost/callback',
87
- storage: createMemoryStorage(),
88
- });
89
- expect(provider).toBeTruthy();
90
- expect(typeof provider.login).toBe('function');
91
- });
92
- });