@startsimpli/auth 0.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 ADDED
@@ -0,0 +1,484 @@
1
+ # @startsimpli/auth
2
+
3
+ Shared authentication package for StartSimpli Next.js applications. Provides JWT-based authentication with Django backend integration.
4
+
5
+ ## Features
6
+
7
+ - JWT access token authentication
8
+ - Automatic token refresh with refresh tokens (httpOnly cookies)
9
+ - React Context and hooks for client-side auth
10
+ - Server-side session validation for SSR and API routes
11
+ - Next.js middleware helpers
12
+ - Auth guards for API routes
13
+ - Permission/role checks (owner > admin > member > viewer)
14
+ - TypeScript support with full type safety
15
+
16
+ ## Installation
17
+
18
+ This package is part of the StartSimpli monorepo. It uses NPM workspaces and TypeScript path aliases for direct source imports during development.
19
+
20
+ ```bash
21
+ # In your Next.js app's package.json
22
+ {
23
+ "dependencies": {
24
+ "@startsimpli/auth": "*"
25
+ }
26
+ }
27
+ ```
28
+
29
+ ## Django Backend Integration
30
+
31
+ This package is designed to work with the StartSimpli Django API (`start-simpli-api`).
32
+
33
+ **Required Django endpoints:**
34
+ - `POST /api/v1/auth/token/` - Login (returns JWT access token + sets refresh token cookie)
35
+ - `POST /api/v1/auth/token/refresh/` - Refresh access token using cookie
36
+ - `POST /api/v1/auth/logout/` - Logout
37
+ - `GET /api/v1/auth/me/` - Get current user data
38
+
39
+ **Token flow:**
40
+ 1. Login returns `{ access: "jwt-token", user: {...} }`
41
+ 2. Refresh token stored as httpOnly cookie (managed by Django)
42
+ 3. Access token stored in memory (not localStorage)
43
+ 4. Automatic refresh before expiration (default: 4 minutes)
44
+
45
+ ## Usage
46
+
47
+ ### Client-Side Authentication
48
+
49
+ #### 1. Setup Auth Provider
50
+
51
+ Wrap your app with `AuthProvider` in your root layout:
52
+
53
+ ```tsx
54
+ // app/layout.tsx
55
+ 'use client';
56
+
57
+ import { AuthProvider } from '@startsimpli/auth/client';
58
+
59
+ export default function RootLayout({ children }) {
60
+ return (
61
+ <html>
62
+ <body>
63
+ <AuthProvider
64
+ config={{
65
+ apiBaseUrl: process.env.NEXT_PUBLIC_API_URL!,
66
+ onSessionExpired: () => {
67
+ window.location.href = '/login';
68
+ },
69
+ }}
70
+ >
71
+ {children}
72
+ </AuthProvider>
73
+ </body>
74
+ </html>
75
+ );
76
+ }
77
+ ```
78
+
79
+ #### 2. Use Authentication Hook
80
+
81
+ ```tsx
82
+ // app/dashboard/page.tsx
83
+ 'use client';
84
+
85
+ import { useAuth } from '@startsimpli/auth/client';
86
+
87
+ export default function DashboardPage() {
88
+ const { user, isLoading, isAuthenticated, logout } = useAuth();
89
+
90
+ if (isLoading) {
91
+ return <div>Loading...</div>;
92
+ }
93
+
94
+ if (!isAuthenticated) {
95
+ return <div>Please login</div>;
96
+ }
97
+
98
+ return (
99
+ <div>
100
+ <h1>Welcome, {user.firstName}!</h1>
101
+ <button onClick={logout}>Logout</button>
102
+ </div>
103
+ );
104
+ }
105
+ ```
106
+
107
+ #### 3. Login Form
108
+
109
+ ```tsx
110
+ // app/login/page.tsx
111
+ 'use client';
112
+
113
+ import { useState } from 'react';
114
+ import { useAuth } from '@startsimpli/auth/client';
115
+ import { useRouter } from 'next/navigation';
116
+
117
+ export default function LoginPage() {
118
+ const { login } = useAuth();
119
+ const router = useRouter();
120
+ const [email, setEmail] = useState('');
121
+ const [password, setPassword] = useState('');
122
+
123
+ const handleSubmit = async (e) => {
124
+ e.preventDefault();
125
+
126
+ try {
127
+ await login(email, password);
128
+ router.push('/dashboard');
129
+ } catch (error) {
130
+ console.error('Login failed:', error);
131
+ }
132
+ };
133
+
134
+ return (
135
+ <form onSubmit={handleSubmit}>
136
+ <input
137
+ type="email"
138
+ value={email}
139
+ onChange={(e) => setEmail(e.target.value)}
140
+ placeholder="Email"
141
+ />
142
+ <input
143
+ type="password"
144
+ value={password}
145
+ onChange={(e) => setPassword(e.target.value)}
146
+ placeholder="Password"
147
+ />
148
+ <button type="submit">Login</button>
149
+ </form>
150
+ );
151
+ }
152
+ ```
153
+
154
+ #### 4. Permission Checks
155
+
156
+ ```tsx
157
+ 'use client';
158
+
159
+ import { usePermissions } from '@startsimpli/auth/client';
160
+
161
+ export default function SettingsPage() {
162
+ const { isAdmin, canEdit, currentRole } = usePermissions();
163
+
164
+ return (
165
+ <div>
166
+ <h1>Settings</h1>
167
+ <p>Your role: {currentRole}</p>
168
+
169
+ {isAdmin() && (
170
+ <button>Admin Settings</button>
171
+ )}
172
+
173
+ {canEdit() ? (
174
+ <button>Edit</button>
175
+ ) : (
176
+ <p>View only</p>
177
+ )}
178
+ </div>
179
+ );
180
+ }
181
+ ```
182
+
183
+ ### Server-Side Authentication
184
+
185
+ #### 1. Protect Server Components
186
+
187
+ ```tsx
188
+ // app/dashboard/page.tsx
189
+ import { getServerSession } from '@startsimpli/auth/server';
190
+ import { redirect } from 'next/navigation';
191
+
192
+ export default async function DashboardPage() {
193
+ const session = await getServerSession(process.env.API_BASE_URL!);
194
+
195
+ if (!session) {
196
+ redirect('/login');
197
+ }
198
+
199
+ return (
200
+ <div>
201
+ <h1>Welcome, {session.user.firstName}!</h1>
202
+ </div>
203
+ );
204
+ }
205
+ ```
206
+
207
+ #### 2. Next.js Middleware
208
+
209
+ Protect routes with authentication middleware:
210
+
211
+ ```ts
212
+ // middleware.ts
213
+ import { createAuthMiddleware } from '@startsimpli/auth/server';
214
+
215
+ export const middleware = createAuthMiddleware({
216
+ apiBaseUrl: process.env.API_BASE_URL!,
217
+ publicPaths: ['/login', '/register', '/forgot-password'],
218
+ loginPath: '/login',
219
+ });
220
+
221
+ export const config = {
222
+ matcher: [
223
+ '/((?!api|_next/static|_next/image|favicon.ico).*)',
224
+ ],
225
+ };
226
+ ```
227
+
228
+ #### 3. API Route Protection
229
+
230
+ Protect API routes with auth guards:
231
+
232
+ ```ts
233
+ // app/api/users/route.ts
234
+ import { NextRequest } from 'next/server';
235
+ import { withAuth } from '@startsimpli/auth/server';
236
+
237
+ export const GET = withAuth(async (request: NextRequest, token: string) => {
238
+ const response = await fetch(`${process.env.API_BASE_URL}/api/v1/users/`, {
239
+ headers: {
240
+ Authorization: `Bearer ${token}`,
241
+ },
242
+ });
243
+
244
+ const data = await response.json();
245
+ return NextResponse.json(data);
246
+ });
247
+ ```
248
+
249
+ #### 4. Role-Based API Protection
250
+
251
+ ```ts
252
+ // app/api/admin/users/route.ts
253
+ import { NextRequest } from 'next/server';
254
+ import { withRole } from '@startsimpli/auth/server';
255
+
256
+ async function getUserRole() {
257
+ // Fetch user's current role from your API
258
+ return 'admin';
259
+ }
260
+
261
+ export const GET = withRole(
262
+ 'admin',
263
+ getUserRole,
264
+ async (request: NextRequest, token: string) => {
265
+ // Only accessible to admins
266
+ return NextResponse.json({ message: 'Admin data' });
267
+ }
268
+ );
269
+ ```
270
+
271
+ ### Making Authenticated API Calls
272
+
273
+ ```tsx
274
+ 'use client';
275
+
276
+ import { useAuth } from '@startsimpli/auth/client';
277
+
278
+ export default function DataFetcher() {
279
+ const { getAccessToken } = useAuth();
280
+
281
+ const fetchData = async () => {
282
+ const token = await getAccessToken();
283
+
284
+ if (!token) {
285
+ console.error('No access token');
286
+ return;
287
+ }
288
+
289
+ const response = await fetch('/api/data', {
290
+ headers: {
291
+ Authorization: `Bearer ${token}`,
292
+ },
293
+ });
294
+
295
+ return response.json();
296
+ };
297
+
298
+ return <button onClick={fetchData}>Fetch Data</button>;
299
+ }
300
+ ```
301
+
302
+ ## API Reference
303
+
304
+ ### Client Exports
305
+
306
+ #### `AuthProvider`
307
+
308
+ React context provider for authentication.
309
+
310
+ ```tsx
311
+ interface AuthProviderProps {
312
+ children: ReactNode;
313
+ config: AuthConfig;
314
+ initialSession?: Session | null;
315
+ }
316
+ ```
317
+
318
+ #### `useAuth()`
319
+
320
+ Hook to access authentication state and methods.
321
+
322
+ ```tsx
323
+ interface UseAuthReturn {
324
+ user: AuthUser | null;
325
+ session: Session | null;
326
+ isLoading: boolean;
327
+ isAuthenticated: boolean;
328
+ login: (email: string, password: string) => Promise<void>;
329
+ logout: () => Promise<void>;
330
+ refreshUser: () => Promise<void>;
331
+ getAccessToken: () => Promise<string | null>;
332
+ }
333
+ ```
334
+
335
+ #### `usePermissions()`
336
+
337
+ Hook for permission/role checks.
338
+
339
+ ```tsx
340
+ interface UsePermissionsReturn {
341
+ hasRole: (requiredRole: CompanyRole, companyId?: string) => boolean;
342
+ isOwner: (companyId?: string) => boolean;
343
+ isAdmin: (companyId?: string) => boolean;
344
+ canEdit: (companyId?: string) => boolean;
345
+ canView: (companyId?: string) => boolean;
346
+ currentRole: CompanyRole | null;
347
+ currentCompanyId: string | null;
348
+ }
349
+ ```
350
+
351
+ #### `AuthClient`
352
+
353
+ Low-level authentication client (used internally by hooks).
354
+
355
+ ```tsx
356
+ class AuthClient {
357
+ constructor(config: AuthConfig);
358
+ login(email: string, password: string): Promise<Session>;
359
+ logout(): Promise<void>;
360
+ refreshToken(): Promise<string>;
361
+ getCurrentUser(): Promise<AuthUser>;
362
+ getSession(): Session | null;
363
+ getAccessToken(): Promise<string | null>;
364
+ }
365
+ ```
366
+
367
+ ### Server Exports
368
+
369
+ #### `getServerSession()`
370
+
371
+ Get authenticated session from server-side cookies.
372
+
373
+ ```tsx
374
+ async function getServerSession(apiBaseUrl: string): Promise<Session | null>;
375
+ ```
376
+
377
+ #### `validateSession()`
378
+
379
+ Validate session and return user or null.
380
+
381
+ ```tsx
382
+ async function validateSession(apiBaseUrl: string): Promise<AuthUser | null>;
383
+ ```
384
+
385
+ #### `requireAuth()`
386
+
387
+ Require authenticated session (throws if not authenticated).
388
+
389
+ ```tsx
390
+ async function requireAuth(apiBaseUrl: string): Promise<Session>;
391
+ ```
392
+
393
+ #### `createAuthMiddleware()`
394
+
395
+ Create Next.js middleware for route protection.
396
+
397
+ ```tsx
398
+ function createAuthMiddleware(config: AuthMiddlewareConfig): Middleware;
399
+ ```
400
+
401
+ #### `withAuth()`
402
+
403
+ HOF to wrap API routes with auth guard.
404
+
405
+ ```tsx
406
+ function withAuth<T>(
407
+ handler: (request: NextRequest, token: string) => Promise<NextResponse<T>>
408
+ ): (request: NextRequest) => Promise<NextResponse<T>>;
409
+ ```
410
+
411
+ #### `withRole()`
412
+
413
+ HOF to wrap API routes with role-based guard.
414
+
415
+ ```tsx
416
+ function withRole<T>(
417
+ requiredRole: CompanyRole,
418
+ getUserRole: () => Promise<CompanyRole | null>,
419
+ handler: (request: NextRequest, token: string) => Promise<NextResponse<T>>
420
+ ): (request: NextRequest) => Promise<NextResponse<T>>;
421
+ ```
422
+
423
+ ### Types
424
+
425
+ ```tsx
426
+ type CompanyRole = 'owner' | 'admin' | 'member' | 'viewer';
427
+
428
+ interface AuthUser {
429
+ id: string;
430
+ email: string;
431
+ firstName: string;
432
+ lastName: string;
433
+ isEmailVerified: boolean;
434
+ createdAt: string;
435
+ updatedAt: string;
436
+ companies?: Array<{
437
+ id: string;
438
+ name: string;
439
+ role: CompanyRole;
440
+ }>;
441
+ currentCompanyId?: string;
442
+ }
443
+
444
+ interface Session {
445
+ user: AuthUser;
446
+ accessToken: string;
447
+ expiresAt: number;
448
+ }
449
+
450
+ interface AuthConfig {
451
+ apiBaseUrl: string;
452
+ tokenRefreshInterval?: number;
453
+ onSessionExpired?: () => void;
454
+ onUnauthorized?: () => void;
455
+ }
456
+ ```
457
+
458
+ ## Role Hierarchy
459
+
460
+ Roles follow a hierarchy where higher roles inherit permissions from lower roles:
461
+
462
+ ```
463
+ owner (4) > admin (3) > member (2) > viewer (1)
464
+ ```
465
+
466
+ Use `hasRolePermission(userRole, requiredRole)` to check if a user has sufficient permissions.
467
+
468
+ ## Testing
469
+
470
+ ```bash
471
+ npm test # Run tests
472
+ npm run test:watch # Watch mode
473
+ npm run test:coverage # Coverage report
474
+ ```
475
+
476
+ ## Development
477
+
478
+ ```bash
479
+ npm run type-check # TypeScript validation
480
+ ```
481
+
482
+ ## License
483
+
484
+ Private package for StartSimpli monorepo.
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@startsimpli/auth",
3
+ "version": "0.1.0",
4
+ "description": "Shared authentication package for StartSimpli Next.js apps",
5
+ "main": "./src/index.ts",
6
+ "types": "./src/index.ts",
7
+ "files": ["src"],
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "exports": {
12
+ ".": "./src/index.ts",
13
+ "./client": "./src/client/index.ts",
14
+ "./server": "./src/server/index.ts",
15
+ "./types": "./src/types/index.ts",
16
+ "./email": "./src/email/index.ts"
17
+ },
18
+ "scripts": {
19
+ "test": "vitest run",
20
+ "test:watch": "vitest",
21
+ "test:coverage": "vitest run --coverage",
22
+ "type-check": "tsc --noEmit"
23
+ },
24
+ "peerDependencies": {
25
+ "react": "^18.0.0 || ^19.0.0",
26
+ "next": "^14.0.0 || ^15.0.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/react": "^18.3.18",
30
+ "@types/node": "^20.17.14",
31
+ "typescript": "^5.7.3",
32
+ "vitest": "^3.0.0",
33
+ "@vitest/ui": "^3.0.0",
34
+ "happy-dom": "^15.11.7"
35
+ },
36
+ "keywords": [
37
+ "authentication",
38
+ "jwt",
39
+ "nextjs",
40
+ "django",
41
+ "startsimpli"
42
+ ]
43
+ }
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { hasRolePermission, ROLE_HIERARCHY } from '../types';
3
+
4
+ describe('Permission checks', () => {
5
+ describe('ROLE_HIERARCHY', () => {
6
+ it('should have correct hierarchy values', () => {
7
+ expect(ROLE_HIERARCHY.owner).toBeGreaterThan(ROLE_HIERARCHY.admin);
8
+ expect(ROLE_HIERARCHY.admin).toBeGreaterThan(ROLE_HIERARCHY.member);
9
+ expect(ROLE_HIERARCHY.member).toBeGreaterThan(ROLE_HIERARCHY.viewer);
10
+ });
11
+ });
12
+
13
+ describe('hasRolePermission', () => {
14
+ it('should allow owner to do everything', () => {
15
+ expect(hasRolePermission('owner', 'owner')).toBe(true);
16
+ expect(hasRolePermission('owner', 'admin')).toBe(true);
17
+ expect(hasRolePermission('owner', 'member')).toBe(true);
18
+ expect(hasRolePermission('owner', 'viewer')).toBe(true);
19
+ });
20
+
21
+ it('should allow admin to do admin, member, viewer actions', () => {
22
+ expect(hasRolePermission('admin', 'owner')).toBe(false);
23
+ expect(hasRolePermission('admin', 'admin')).toBe(true);
24
+ expect(hasRolePermission('admin', 'member')).toBe(true);
25
+ expect(hasRolePermission('admin', 'viewer')).toBe(true);
26
+ });
27
+
28
+ it('should allow member to do member and viewer actions', () => {
29
+ expect(hasRolePermission('member', 'owner')).toBe(false);
30
+ expect(hasRolePermission('member', 'admin')).toBe(false);
31
+ expect(hasRolePermission('member', 'member')).toBe(true);
32
+ expect(hasRolePermission('member', 'viewer')).toBe(true);
33
+ });
34
+
35
+ it('should allow viewer to do only viewer actions', () => {
36
+ expect(hasRolePermission('viewer', 'owner')).toBe(false);
37
+ expect(hasRolePermission('viewer', 'admin')).toBe(false);
38
+ expect(hasRolePermission('viewer', 'member')).toBe(false);
39
+ expect(hasRolePermission('viewer', 'viewer')).toBe(true);
40
+ });
41
+ });
42
+ });
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ decodeToken,
4
+ isTokenExpired,
5
+ getTokenExpiresAt,
6
+ shouldRefreshToken,
7
+ } from '../utils/token';
8
+
9
+ describe('Token utilities', () => {
10
+ describe('decodeToken', () => {
11
+ it('should decode valid JWT token', () => {
12
+ const token =
13
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzM5MjI3MjAwLCJpYXQiOjE3MzkxNDA4MDAsImp0aSI6InRlc3QtanRpIiwidXNlcl9pZCI6IjEyMzQ1In0.test-signature';
14
+
15
+ const payload = decodeToken(token);
16
+
17
+ expect(payload).toEqual({
18
+ token_type: 'access',
19
+ exp: 1739227200,
20
+ iat: 1739140800,
21
+ jti: 'test-jti',
22
+ user_id: '12345',
23
+ });
24
+ });
25
+
26
+ it('should return null for invalid token', () => {
27
+ const payload = decodeToken('invalid.token');
28
+ expect(payload).toBeNull();
29
+ });
30
+
31
+ it('should return null for malformed token', () => {
32
+ const payload = decodeToken('not-a-token');
33
+ expect(payload).toBeNull();
34
+ });
35
+ });
36
+
37
+ describe('isTokenExpired', () => {
38
+ it('should return false for valid token', () => {
39
+ const futureTime = Math.floor(Date.now() / 1000) + 3600;
40
+ const token = createTestToken({ exp: futureTime });
41
+
42
+ expect(isTokenExpired(token)).toBe(false);
43
+ });
44
+
45
+ it('should return true for expired token', () => {
46
+ const pastTime = Math.floor(Date.now() / 1000) - 3600;
47
+ const token = createTestToken({ exp: pastTime });
48
+
49
+ expect(isTokenExpired(token)).toBe(true);
50
+ });
51
+
52
+ it('should return true for invalid token', () => {
53
+ expect(isTokenExpired('invalid')).toBe(true);
54
+ });
55
+ });
56
+
57
+ describe('getTokenExpiresAt', () => {
58
+ it('should return expiration timestamp', () => {
59
+ const exp = Math.floor(Date.now() / 1000) + 3600;
60
+ const token = createTestToken({ exp });
61
+
62
+ const expiresAt = getTokenExpiresAt(token);
63
+ expect(expiresAt).toBe(exp * 1000);
64
+ });
65
+
66
+ it('should return null for invalid token', () => {
67
+ const expiresAt = getTokenExpiresAt('invalid');
68
+ expect(expiresAt).toBeNull();
69
+ });
70
+ });
71
+
72
+ describe('shouldRefreshToken', () => {
73
+ it('should return true when token expires in less than 5 minutes', () => {
74
+ const exp = Math.floor(Date.now() / 1000) + 240; // 4 minutes
75
+ const token = createTestToken({ exp });
76
+
77
+ expect(shouldRefreshToken(token)).toBe(true);
78
+ });
79
+
80
+ it('should return false when token expires in more than 5 minutes', () => {
81
+ const exp = Math.floor(Date.now() / 1000) + 600; // 10 minutes
82
+ const token = createTestToken({ exp });
83
+
84
+ expect(shouldRefreshToken(token)).toBe(false);
85
+ });
86
+
87
+ it('should return true for invalid token', () => {
88
+ expect(shouldRefreshToken('invalid')).toBe(true);
89
+ });
90
+ });
91
+ });
92
+
93
+ function createTestToken(payload: Record<string, any>): string {
94
+ const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
95
+ const body = btoa(JSON.stringify(payload));
96
+ return `${header}.${body}.signature`;
97
+ }