@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 +484 -0
- package/package.json +43 -0
- package/src/__tests__/permissions.test.ts +42 -0
- package/src/__tests__/token.test.ts +97 -0
- package/src/client/auth-client.ts +265 -0
- package/src/client/auth-context.tsx +153 -0
- package/src/client/functions.ts +424 -0
- package/src/client/index.ts +5 -0
- package/src/client/use-auth.ts +45 -0
- package/src/client/use-permissions.ts +82 -0
- package/src/email/index.ts +136 -0
- package/src/index.ts +41 -0
- package/src/server/guards.ts +113 -0
- package/src/server/index.ts +20 -0
- package/src/server/middleware.ts +106 -0
- package/src/server/session.ts +115 -0
- package/src/types/index.ts +142 -0
- package/src/utils/cookies.ts +86 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/token.ts +89 -0
- package/src/validation/index.ts +34 -0
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
|
+
}
|