@classic-homes/auth 0.1.22 → 0.1.24

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 CHANGED
@@ -5,8 +5,9 @@ Framework-agnostic authentication core with Svelte bindings for the Classic Them
5
5
  ## Features
6
6
 
7
7
  - JWT-based authentication with automatic token refresh
8
- - SSO (Single Sign-On) support with configurable providers
9
- - Multi-factor authentication (MFA/TOTP) support
8
+ - SSO (Single Sign-On) support with configurable providers and logout
9
+ - Multi-factor authentication (MFA/TOTP) support with type guards
10
+ - Auto-set auth state on successful login
10
11
  - Pluggable storage adapter (localStorage, sessionStorage, or custom)
11
12
  - Svelte reactive stores for authentication state
12
13
  - Route guards for protected pages
@@ -26,6 +27,8 @@ In your app's entry point (e.g., `hooks.client.ts` for SvelteKit):
26
27
 
27
28
  ```typescript
28
29
  import { initAuth } from '@classic-homes/auth';
30
+ import { goto } from '$app/navigation';
31
+ import { base } from '$app/paths';
29
32
 
30
33
  initAuth({
31
34
  baseUrl: 'https://api.example.com',
@@ -34,47 +37,163 @@ initAuth({
34
37
  setItem: (key, value) => localStorage.setItem(key, value),
35
38
  removeItem: (key) => localStorage.removeItem(key),
36
39
  },
40
+ // SSO configuration (optional)
41
+ sso: {
42
+ enabled: true,
43
+ provider: 'authentik',
44
+ },
45
+ // Callback when auth errors occur
37
46
  onAuthError: (error) => {
38
47
  console.error('Auth error:', error);
39
48
  },
49
+ // Callback when tokens are refreshed
50
+ onTokenRefresh: (tokens) => {
51
+ console.log('Tokens refreshed');
52
+ },
53
+ // Callback when user is logged out
54
+ onLogout: () => {
55
+ goto(`${base}/auth/login`);
56
+ },
40
57
  });
41
58
  ```
42
59
 
43
- ### 2. Use the Auth Store (Svelte)
60
+ ### 2. Login with Auto-Set Auth
61
+
62
+ The `authService.login()` method automatically sets the auth state on successful login:
63
+
64
+ ```typescript
65
+ import {
66
+ authService,
67
+ isMfaChallengeResponse,
68
+ getMfaToken,
69
+ getAvailableMethods,
70
+ } from '@classic-homes/auth/core';
71
+ import { goto } from '$app/navigation';
72
+
73
+ async function handleLogin(email: string, password: string) {
74
+ const response = await authService.login({
75
+ username: email,
76
+ password: password,
77
+ });
78
+
79
+ // Check if MFA is required
80
+ if (isMfaChallengeResponse(response)) {
81
+ const mfaToken = getMfaToken(response);
82
+ const methods = getAvailableMethods(response);
83
+ // Redirect to MFA challenge page
84
+ goto(`/auth/mfa-challenge?token=${mfaToken}&methods=${methods.join(',')}`);
85
+ return;
86
+ }
87
+
88
+ // Auth state is automatically set - redirect to dashboard
89
+ goto('/dashboard');
90
+ }
91
+ ```
92
+
93
+ To disable auto-set auth (for manual control):
94
+
95
+ ```typescript
96
+ const response = await authService.login(credentials, { autoSetAuth: false });
97
+ // Manually set auth state
98
+ authActions.setAuth(response.accessToken, response.refreshToken, response.user);
99
+ ```
100
+
101
+ ### 3. Use the Auth Store (Svelte)
44
102
 
45
103
  ```svelte
46
104
  <script lang="ts">
47
- import { authStore, authActions } from '@classic-homes/auth/svelte';
105
+ import { authStore, authActions, isAuthenticated, currentUser } from '@classic-homes/auth/svelte';
48
106
 
49
- async function handleLogin() {
50
- await authActions.login({
51
- email: 'user@example.com',
52
- password: 'password123',
53
- });
54
- }
107
+ // Using derived stores
108
+ // $isAuthenticated - boolean
109
+ // $currentUser - User | null
55
110
 
56
111
  async function handleLogout() {
57
- await authActions.logout();
112
+ // SSO-aware logout
113
+ const result = await authActions.logoutWithSSO();
114
+ if (result.ssoLogoutUrl) {
115
+ // Redirect to SSO provider logout
116
+ window.location.href = result.ssoLogoutUrl;
117
+ } else {
118
+ goto('/auth/login');
119
+ }
58
120
  }
59
121
  </script>
60
122
 
61
- {#if $authStore.isAuthenticated}
62
- <p>Welcome, {$authStore.user?.email}</p>
123
+ {#if $isAuthenticated}
124
+ <p>Welcome, {$currentUser?.firstName}</p>
63
125
  <button onclick={handleLogout}>Logout</button>
64
126
  {:else}
65
- <button onclick={handleLogin}>Login</button>
127
+ <a href="/auth/login">Login</a>
66
128
  {/if}
67
129
  ```
68
130
 
69
- ### 3. Protect Routes
131
+ ### 4. SSO Login with Redirect URLs
132
+
133
+ ```typescript
134
+ import { authService } from '@classic-homes/auth/core';
135
+
136
+ function handleSSOLogin(redirectUrl: string) {
137
+ // Specify where to redirect after SSO callback
138
+ authService.initiateSSOLogin({
139
+ callbackUrl: `${window.location.origin}/auth/sso-callback`,
140
+ redirectUrl: redirectUrl, // Final destination after auth
141
+ });
142
+ }
143
+ ```
144
+
145
+ ### 5. MFA Challenge Verification
146
+
147
+ ```typescript
148
+ import { authService } from '@classic-homes/auth/core';
149
+
150
+ async function handleMFAVerify(mfaToken: string, code: string, trustDevice: boolean) {
151
+ // Auto-sets auth state on success
152
+ const response = await authService.verifyMFAChallenge({
153
+ mfaToken,
154
+ code,
155
+ method: 'totp',
156
+ trustDevice,
157
+ });
158
+
159
+ // Auth state is automatically set - redirect to dashboard
160
+ goto('/dashboard');
161
+ }
162
+ ```
163
+
164
+ ### 6. Protect Routes
70
165
 
71
166
  ```typescript
72
167
  // src/routes/dashboard/+page.ts
73
- import { authGuard } from '@classic-homes/auth/svelte';
168
+ import { checkAuth, requireRole } from '@classic-homes/auth/svelte';
169
+ import { redirect } from '@sveltejs/kit';
170
+ import { browser } from '$app/environment';
171
+
172
+ export function load({ url }) {
173
+ if (browser) {
174
+ const result = checkAuth();
175
+ if (!result.allowed) {
176
+ throw redirect(302, `/auth/login?redirect=${encodeURIComponent(url.pathname)}`);
177
+ }
178
+ }
179
+ return {};
180
+ }
74
181
 
75
- export const load = authGuard({
76
- redirectTo: '/login',
77
- });
182
+ // For role-based access:
183
+ export function load({ url }) {
184
+ if (browser) {
185
+ const result = checkAuth({ roles: ['admin', 'manager'] });
186
+ if (!result.allowed) {
187
+ if (result.reason === 'not_authenticated') {
188
+ throw redirect(302, `/auth/login?redirect=${encodeURIComponent(url.pathname)}`);
189
+ }
190
+ if (result.reason === 'missing_role') {
191
+ throw redirect(302, '/unauthorized');
192
+ }
193
+ }
194
+ }
195
+ return {};
196
+ }
78
197
  ```
79
198
 
80
199
  ## API Reference
@@ -85,7 +204,8 @@ export const load = authGuard({
85
204
  import {
86
205
  // Initialization
87
206
  initAuth,
88
- getAuthConfig,
207
+ getConfig,
208
+ isInitialized,
89
209
 
90
210
  // Service
91
211
  authService,
@@ -94,13 +214,28 @@ import {
94
214
  // API
95
215
  authApi,
96
216
 
217
+ // MFA Guards
218
+ isMfaChallengeResponse,
219
+ isLoginSuccessResponse,
220
+ getMfaToken,
221
+ getAvailableMethods,
222
+
223
+ // JWT Utilities
224
+ decodeJWT,
225
+ isTokenExpired,
226
+ getTokenRemainingTime,
227
+
97
228
  // Types
98
229
  type User,
99
- type AuthTokens,
230
+ type AuthState,
100
231
  type LoginCredentials,
232
+ type LoginResponse,
233
+ type LogoutResponse,
101
234
  type RegisterData,
102
235
  type AuthConfig,
103
- } from '@classic-homes/auth';
236
+ type LoginOptions,
237
+ type MFAVerifyOptions,
238
+ } from '@classic-homes/auth/core';
104
239
  ```
105
240
 
106
241
  ### Svelte Exports
@@ -116,8 +251,12 @@ import {
116
251
  authActions,
117
252
 
118
253
  // Guards
119
- authGuard,
120
- roleGuard,
254
+ checkAuth,
255
+ createAuthGuard,
256
+ requireAuth,
257
+ requireRole,
258
+ requirePermission,
259
+ protectedLoad,
121
260
  } from '@classic-homes/auth/svelte';
122
261
  ```
123
262
 
@@ -128,26 +267,30 @@ interface AuthConfig {
128
267
  /** Base URL for the auth API */
129
268
  baseUrl: string;
130
269
 
131
- /** Storage adapter for tokens */
132
- storage?: {
133
- getItem: (key: string) => string | null;
134
- setItem: (key: string, value: string) => void;
135
- removeItem: (key: string) => void;
136
- };
270
+ /** Custom fetch implementation (useful for SSR or testing) */
271
+ fetch?: typeof fetch;
272
+
273
+ /** Storage adapter for token persistence */
274
+ storage?: StorageAdapter;
275
+
276
+ /** Storage key prefix for auth data */
277
+ storageKey?: string;
137
278
 
138
279
  /** SSO configuration */
139
280
  sso?: {
140
281
  enabled: boolean;
141
282
  provider: string;
142
283
  authorizeUrl?: string;
143
- tokenUrl?: string;
144
284
  };
145
285
 
146
286
  /** Callback when auth errors occur */
147
287
  onAuthError?: (error: Error) => void;
148
288
 
149
- /** Custom headers for API requests */
150
- headers?: Record<string, string>;
289
+ /** Callback when tokens are refreshed */
290
+ onTokenRefresh?: (tokens: { accessToken: string; refreshToken: string }) => void;
291
+
292
+ /** Callback when user is logged out */
293
+ onLogout?: () => void;
151
294
  }
152
295
  ```
153
296
 
@@ -156,69 +299,47 @@ interface AuthConfig {
156
299
  The `authActions` object provides methods for authentication operations:
157
300
 
158
301
  ```typescript
159
- // Login with email/password
160
- await authActions.login({ email, password, rememberMe });
302
+ // Set auth data after login
303
+ authActions.setAuth(accessToken, refreshToken, user, sessionToken);
161
304
 
162
- // Register new user
163
- await authActions.register({ email, password, fullName });
305
+ // Update tokens after refresh
306
+ authActions.updateTokens(accessToken, refreshToken);
164
307
 
165
- // Logout
166
- await authActions.logout();
308
+ // Update user profile
309
+ authActions.updateUser(user);
167
310
 
168
- // Refresh session
169
- await authActions.refreshSession();
311
+ // Clear auth state (local logout)
312
+ authActions.logout();
170
313
 
171
- // Change password
172
- await authActions.changePassword({ currentPassword, newPassword });
314
+ // SSO-aware logout (calls API, returns SSO logout URL if applicable)
315
+ const result = await authActions.logoutWithSSO();
316
+ if (result.ssoLogoutUrl) {
317
+ window.location.href = result.ssoLogoutUrl;
318
+ }
173
319
 
174
- // MFA operations
175
- await authActions.verifyMfa({ code, trustDevice });
176
- await authActions.setupMfa({ secret, code });
177
- await authActions.disableMfa({ password });
320
+ // Permission and role checks
321
+ authActions.hasPermission('users:read');
322
+ authActions.hasRole('admin');
323
+ authActions.hasAnyRole(['admin', 'manager']);
324
+ authActions.hasAllRoles(['admin', 'manager']);
325
+ authActions.hasAnyPermission(['users:read', 'users:write']);
326
+ authActions.hasAllPermissions(['users:read', 'users:write']);
178
327
 
179
- // SSO
180
- await authActions.initiateSSO();
181
- await authActions.handleSSOCallback(code);
328
+ // Reload auth from storage
329
+ authActions.rehydrate();
182
330
  ```
183
331
 
184
332
  ## Auth Store State
185
333
 
186
334
  ```typescript
187
335
  interface AuthState {
336
+ accessToken: string | null;
337
+ refreshToken: string | null;
188
338
  user: User | null;
189
- tokens: AuthTokens | null;
190
339
  isAuthenticated: boolean;
191
- isLoading: boolean;
192
- error: string | null;
193
- mfaRequired: boolean;
194
- mfaToken: string | null;
195
340
  }
196
341
  ```
197
342
 
198
- ## Route Guards
199
-
200
- ### Basic Auth Guard
201
-
202
- ```typescript
203
- import { authGuard } from '@classic-homes/auth/svelte';
204
-
205
- export const load = authGuard({
206
- redirectTo: '/login',
207
- returnUrl: true, // Append ?returnUrl= to redirect
208
- });
209
- ```
210
-
211
- ### Role-Based Guard
212
-
213
- ```typescript
214
- import { roleGuard } from '@classic-homes/auth/svelte';
215
-
216
- export const load = roleGuard({
217
- roles: ['admin', 'manager'],
218
- redirectTo: '/unauthorized',
219
- });
220
- ```
221
-
222
343
  ## Using with @classic-homes/theme-svelte
223
344
 
224
345
  The auth package integrates with the form validation from `@classic-homes/theme-svelte`:
@@ -226,7 +347,8 @@ The auth package integrates with the form validation from `@classic-homes/theme-
226
347
  ```svelte
227
348
  <script lang="ts">
228
349
  import { useForm, loginSchema } from '@classic-homes/theme-svelte';
229
- import { authActions } from '@classic-homes/auth/svelte';
350
+ import { authService, isMfaChallengeResponse, getMfaToken } from '@classic-homes/auth/core';
351
+ import { goto } from '$app/navigation';
230
352
 
231
353
  const form = useForm({
232
354
  schema: loginSchema,
@@ -236,7 +358,19 @@ The auth package integrates with the form validation from `@classic-homes/theme-
236
358
  rememberMe: false,
237
359
  },
238
360
  onSubmit: async (data) => {
239
- await authActions.login(data);
361
+ const response = await authService.login({
362
+ username: data.email,
363
+ password: data.password,
364
+ });
365
+
366
+ if (isMfaChallengeResponse(response)) {
367
+ const mfaToken = getMfaToken(response);
368
+ goto(`/auth/mfa-challenge?token=${mfaToken}`);
369
+ return;
370
+ }
371
+
372
+ // Auth state automatically set
373
+ goto('/dashboard');
240
374
  },
241
375
  });
242
376
  </script>
@@ -250,6 +384,277 @@ The auth package integrates with the form validation from `@classic-homes/theme-
250
384
  </form>
251
385
  ```
252
386
 
387
+ ## Automatic Token Refresh
388
+
389
+ Token refresh happens automatically when:
390
+
391
+ - An API request returns 401 Unauthorized
392
+ - The refresh token is valid
393
+
394
+ The Svelte store is automatically updated when tokens are refreshed, so your UI stays in sync.
395
+
396
+ ## Testing Utilities
397
+
398
+ The auth package includes comprehensive testing utilities for unit and integration tests.
399
+
400
+ ### Installation
401
+
402
+ ```bash
403
+ # The testing utilities are included in the main package
404
+ npm install @classic-homes/auth
405
+ ```
406
+
407
+ ### Quick Start
408
+
409
+ ```typescript
410
+ import { describe, it, beforeEach, afterEach, expect } from 'vitest';
411
+ import {
412
+ setupTestAuth,
413
+ mockUser,
414
+ configureMFAFlow,
415
+ assertAuthenticated,
416
+ } from '@classic-homes/auth/testing';
417
+ import { authService, isMfaChallengeResponse } from '@classic-homes/auth/core';
418
+
419
+ describe('Login Flow', () => {
420
+ let cleanup: () => void;
421
+ let mockFetch;
422
+
423
+ beforeEach(() => {
424
+ const ctx = setupTestAuth();
425
+ cleanup = ctx.cleanup;
426
+ mockFetch = ctx.mockFetch;
427
+ });
428
+
429
+ afterEach(() => cleanup());
430
+
431
+ it('handles successful login', async () => {
432
+ const response = await authService.login({
433
+ username: 'test@example.com',
434
+ password: 'password',
435
+ });
436
+
437
+ expect(response.user).toMatchObject(mockUser);
438
+ mockFetch.assertCalled('/auth/login');
439
+ });
440
+
441
+ it('handles MFA flow', async () => {
442
+ configureMFAFlow(mockFetch);
443
+
444
+ const response = await authService.login({
445
+ username: 'test@example.com',
446
+ password: 'password',
447
+ });
448
+
449
+ expect(isMfaChallengeResponse(response)).toBe(true);
450
+ });
451
+ });
452
+ ```
453
+
454
+ ### Testing Exports
455
+
456
+ ```typescript
457
+ import {
458
+ // Fixtures - Pre-defined test data
459
+ mockUser,
460
+ mockAdminUser,
461
+ mockSSOUser,
462
+ mockMFAUser,
463
+ mockAccessToken,
464
+ mockRefreshToken,
465
+ mockLoginSuccess,
466
+ mockMFARequired,
467
+ createMockUser,
468
+ createMockTokenPair,
469
+ createMockLoginSuccess,
470
+
471
+ // Mocks - Test doubles for dependencies
472
+ MockStorageAdapter,
473
+ MockFetchInstance,
474
+ MockAuthStore,
475
+ createMockStorage,
476
+ createMockFetch,
477
+ createMockAuthStore,
478
+
479
+ // Setup Helpers
480
+ setupTestAuth,
481
+ createTestAuthHelpers,
482
+ quickSetupAuth,
483
+ withTestAuth,
484
+
485
+ // State Simulation
486
+ authScenarios,
487
+ applyScenario,
488
+ configureMFAFlow,
489
+ configureTokenRefresh,
490
+ configureSSOLogout,
491
+ simulateLogin,
492
+ simulateLogout,
493
+
494
+ // Assertions
495
+ assertAuthenticated,
496
+ assertUnauthenticated,
497
+ assertHasPermissions,
498
+ assertHasRoles,
499
+ assertTokenValid,
500
+ assertApiCalled,
501
+ assertStoreMethodCalled,
502
+ assertRequiresMFA,
503
+ } from '@classic-homes/auth/testing';
504
+ ```
505
+
506
+ ### Mock Fetch
507
+
508
+ The `MockFetchInstance` provides a configurable mock fetch with pre-defined auth routes:
509
+
510
+ ```typescript
511
+ const ctx = setupTestAuth();
512
+ const { mockFetch } = ctx;
513
+
514
+ // Default routes are pre-configured for all auth endpoints
515
+
516
+ // Customize responses
517
+ mockFetch.requireMFA(); // Login requires MFA
518
+ mockFetch.failLogin('Invalid credentials'); // Login fails
519
+ mockFetch.enableSSOLogout(); // Logout returns SSO URL
520
+
521
+ // Add custom routes
522
+ mockFetch.addRoute({
523
+ method: 'GET',
524
+ path: '/custom/endpoint',
525
+ response: { data: 'custom response' },
526
+ });
527
+
528
+ // Fail specific endpoints
529
+ mockFetch.failEndpoint('GET', '/auth/profile', 403, 'Forbidden');
530
+
531
+ // Check call history
532
+ expect(mockFetch.wasCalled('/auth/login')).toBe(true);
533
+ mockFetch.assertCalled('/auth/profile');
534
+ mockFetch.assertNotCalled('/auth/logout');
535
+ ```
536
+
537
+ ### Mock Auth Store
538
+
539
+ The `MockAuthStore` mimics the Svelte auth store:
540
+
541
+ ```typescript
542
+ const store = createMockAuthStore();
543
+
544
+ // Simulate states
545
+ store.simulateAuthenticated(mockAdminUser);
546
+ store.simulateUnauthenticated();
547
+
548
+ // Direct state manipulation
549
+ store.setState({ isAuthenticated: true, user: mockUser });
550
+
551
+ // Check method calls
552
+ store.assertMethodCalled('setAuth');
553
+ store.assertMethodNotCalled('logout');
554
+
555
+ // Get call history
556
+ const calls = store.getCallsFor('setAuth');
557
+ ```
558
+
559
+ ### Pre-defined Scenarios
560
+
561
+ Apply common auth scenarios for testing:
562
+
563
+ ```typescript
564
+ import { authScenarios, applyScenario } from '@classic-homes/auth/testing';
565
+
566
+ // Available scenarios:
567
+ // - 'unauthenticated'
568
+ // - 'authenticated'
569
+ // - 'admin'
570
+ // - 'ssoUser'
571
+ // - 'mfaEnabled'
572
+ // - 'unverifiedEmail'
573
+ // - 'inactive'
574
+ // - 'expiredToken'
575
+
576
+ const store = createMockAuthStore();
577
+ applyScenario(store, 'admin');
578
+ expect(store.user?.roles).toContain('admin');
579
+ ```
580
+
581
+ ### Custom Assertions
582
+
583
+ Use built-in assertions for common checks:
584
+
585
+ ```typescript
586
+ import {
587
+ assertAuthenticated,
588
+ assertHasPermissions,
589
+ assertTokenValid,
590
+ assertApiCalled,
591
+ assertRequiresMFA,
592
+ } from '@classic-homes/auth/testing';
593
+
594
+ // Auth state assertions
595
+ assertAuthenticated(store.getState());
596
+ assertUnauthenticated(store.getState());
597
+
598
+ // Permission assertions
599
+ assertHasPermissions(user, ['read:profile', 'write:profile']);
600
+ assertHasRoles(user, ['admin']);
601
+
602
+ // Token assertions
603
+ assertTokenValid(accessToken);
604
+ assertTokenExpired(oldToken);
605
+
606
+ // API call assertions
607
+ assertApiCalled(mockFetch, 'POST', '/auth/login', {
608
+ times: 1,
609
+ body: { username: 'test', password: 'pass' },
610
+ });
611
+
612
+ // MFA assertions
613
+ assertRequiresMFA(loginResponse);
614
+ assertNoMFARequired(loginResponse);
615
+ ```
616
+
617
+ ### Isolated Test Context
618
+
619
+ Run tests in isolated auth contexts:
620
+
621
+ ```typescript
622
+ import { withTestAuth } from '@classic-homes/auth/testing';
623
+
624
+ // Automatic setup and cleanup
625
+ await withTestAuth(async ({ mockFetch, mockStore }) => {
626
+ mockFetch.requireMFA();
627
+ const response = await authService.login({ username: 'test', password: 'pass' });
628
+ expect(response.requiresMFA).toBe(true);
629
+ });
630
+ ```
631
+
632
+ ### User Fixtures
633
+
634
+ Create custom test users:
635
+
636
+ ```typescript
637
+ import {
638
+ mockUser,
639
+ mockAdminUser,
640
+ createMockUser,
641
+ createMockUserWithRoles,
642
+ } from '@classic-homes/auth/testing';
643
+
644
+ // Use pre-defined users
645
+ expect(mockUser.role).toBe('user');
646
+ expect(mockAdminUser.permissions).toContain('manage:system');
647
+
648
+ // Create custom users
649
+ const customUser = createMockUser({
650
+ email: 'custom@example.com',
651
+ firstName: 'Custom',
652
+ });
653
+
654
+ // Create users with specific RBAC
655
+ const managerUser = createMockUserWithRoles(['manager', 'user'], ['read:reports', 'write:reports']);
656
+ ```
657
+
253
658
  ## License
254
659
 
255
660
  MIT