Skip to content

Commit 6f9507c

Browse files
committed
feat: add endpoint-level authentication support
- Introduced `authenticated: true` option for endpoints to require Bearer token authentication. - Updated README with detailed authentication instructions and examples. - Enhanced API server to handle 401/403 responses for unauthorized access. - Added new test file for user management endpoints with authentication. - Refactored existing endpoints to utilize the new authentication feature.
1 parent 8f3ff69 commit 6f9507c

File tree

4 files changed

+521
-227
lines changed

4 files changed

+521
-227
lines changed

README.md

Lines changed: 91 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ app.createEndpoint({
154154
query: QuerySchema, // Zod schema for query params
155155
body: BodySchema, // Zod schema for request body
156156
response: ResponseSchema, // Zod schema for response
157+
authenticated: true, // Optional: Require Bearer token (adds 401/403 responses)
157158
config: { // Optional Swagger metadata
158159
description: 'Create user',
159160
tags: ['Users'],
@@ -162,6 +163,7 @@ app.createEndpoint({
162163
handler: async (request, reply) => {
163164
// Fully typed request.query and request.body
164165
// Return value is validated against ResponseSchema
166+
// If authenticated: true, request.auth is available
165167
return { /* response data */ };
166168
},
167169
});
@@ -192,10 +194,56 @@ app.setupGracefulShutdown();
192194
await app.start();
193195
```
194196

197+
## Authentication
198+
199+
The library provides flexible authentication with automatic OpenAPI documentation.
200+
201+
### Endpoint-Level Authentication (Recommended)
202+
203+
Add `authenticated: true` to any endpoint to require Bearer token authentication. This automatically:
204+
- Adds Bearer auth button in Swagger UI
205+
- Includes 401/403 error responses in OpenAPI spec
206+
- Applies authentication middleware
207+
- Validates tokens before your handler runs
208+
209+
```typescript
210+
const app = new APIServer({
211+
apiToken: 'your-secret-token',
212+
});
213+
214+
// Public endpoint - no authentication
215+
app.createEndpoint({
216+
method: 'GET',
217+
url: '/public',
218+
response: z.object({ message: z.string() }),
219+
handler: async () => {
220+
return { message: 'Anyone can access this!' };
221+
},
222+
});
223+
224+
// Protected endpoint - requires authentication
225+
app.createEndpoint({
226+
method: 'GET',
227+
url: '/protected',
228+
authenticated: true, // 🔒 Requires Bearer token
229+
response: z.object({ secret: z.string() }),
230+
handler: async () => {
231+
return { secret: 'Only authenticated users see this!' };
232+
},
233+
});
234+
235+
// Usage:
236+
// curl http://localhost:3000/public (works)
237+
// curl -H "Authorization: Bearer your-secret-token" http://localhost:3000/protected (works)
238+
// curl http://localhost:3000/protected (401 Unauthorized)
239+
```
240+
195241
### `app.authenticateToken`
196242

197243
Bearer token authentication middleware. Supports both simple string token validation and custom validation with typed context.
198244

245+
**Note:** Using `authenticated: true` on endpoints is preferred over manually registering authentication middleware, as it provides better OpenAPI documentation.
246+
199247
#### Simple Token Authentication
200248

201249
```typescript
@@ -253,23 +301,53 @@ const app = new APIServer<AuthContext>({
253301
},
254302
});
255303

256-
// Protected endpoint with access to auth context
304+
// Protected endpoint with access to auth context (using authenticated: true)
305+
app.createEndpoint({
306+
method: 'GET',
307+
url: '/profile',
308+
authenticated: true, // 🔒 Requires Bearer token
309+
response: z.object({ userId: z.string(), role: z.string() }),
310+
handler: async (request) => {
311+
// request.auth is fully typed as AuthContext
312+
const { userId, role } = request.auth!;
313+
return { userId, role };
314+
},
315+
});
316+
317+
// Admin-only endpoint with role checking
318+
app.createEndpoint({
319+
method: 'DELETE',
320+
url: '/admin/users/:id',
321+
authenticated: true, // 🔒 Requires Bearer token
322+
response: z.object({ message: z.string() }),
323+
handler: async (request, reply) => {
324+
// Custom role check
325+
if (request.auth!.role !== 'admin') {
326+
return reply.code(403).send({
327+
statusCode: 403,
328+
error: 'Forbidden',
329+
message: 'Admin access required',
330+
});
331+
}
332+
333+
const { id } = request.params as { id: string };
334+
return { message: `User ${id} deleted` };
335+
},
336+
});
337+
338+
// Usage: curl -H "Authorization: Bearer user-jwt-token" http://localhost:3000/profile
339+
```
340+
341+
**Alternative:** Use `app.instance.register()` with `scope.addHook()` if you need to protect multiple routes at once:
342+
343+
```typescript
257344
app.instance.register(async (scope) => {
258345
scope.addHook('onRequest', app.authenticateToken);
259346

260-
app.createEndpoint({
261-
method: 'GET',
262-
url: '/profile',
263-
response: z.object({ userId: z.string(), role: z.string() }),
264-
handler: async (request) => {
265-
// request.auth is fully typed as AuthContext
266-
const { userId, role } = request.auth!;
267-
return { userId, role };
268-
},
269-
});
347+
// All routes here will require authentication
348+
scope.get('/admin/stats', async () => ({ total: 100 }));
349+
scope.post('/admin/settings', async () => ({ success: true }));
270350
});
271-
272-
// Usage: curl -H "Authorization: Bearer user-jwt-token" http://localhost:3000/profile
273351
```
274352

275353
### `app.instance`

examples/custom-auth.ts

Lines changed: 86 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -81,113 +81,92 @@ app.createEndpoint({
8181
},
8282
});
8383

84-
// Protected endpoints with authentication
85-
app.instance.register(async (protectedScope) => {
86-
// Add authentication middleware to all routes in this scope
87-
protectedScope.addHook('onRequest', app.authenticateToken);
88-
89-
// Profile endpoint - uses auth context
90-
app.createEndpoint({
91-
method: 'GET',
92-
url: '/profile',
93-
response: z.object({
94-
userId: z.string(),
95-
username: z.string(),
96-
role: z.string(),
97-
permissions: z.array(z.string()),
98-
}),
99-
config: {
100-
description: 'Get current user profile from auth context',
101-
tags: ['Protected'],
102-
},
103-
handler: async (request, reply) => {
104-
// request.auth is fully typed as AuthContext
105-
if (!request.auth) {
106-
return reply.code(401).send({
107-
statusCode: 401,
108-
error: 'Unauthorized',
109-
message: 'Authentication required',
110-
});
111-
}
112-
113-
const { userId, username, role, permissions } = request.auth;
114-
115-
return {
116-
userId,
117-
username,
118-
role,
119-
permissions,
120-
};
121-
},
122-
});
123-
124-
// Admin-only endpoint
125-
app.createEndpoint({
126-
method: 'DELETE',
127-
url: '/admin/users/:userId',
128-
response: z.object({ message: z.string() }),
129-
config: {
130-
description: 'Admin-only endpoint for deleting users',
131-
tags: ['Admin'],
132-
},
133-
handler: async (request, reply) => {
134-
if (!request.auth) {
135-
return reply.code(401).send({
136-
statusCode: 401,
137-
error: 'Unauthorized',
138-
message: 'Authentication required',
139-
});
140-
}
141-
142-
// Check if user has admin role
143-
if (request.auth.role !== 'admin') {
144-
return reply.code(403).send({
145-
statusCode: 403,
146-
error: 'Forbidden',
147-
message: 'Admin access required',
148-
});
149-
}
150-
151-
const userId = (request.params as { userId: string }).userId;
152-
return {
153-
message: `User ${userId} deleted by ${request.auth.username}`,
154-
};
155-
},
156-
});
157-
158-
// Check permissions example
159-
app.createEndpoint({
160-
method: 'POST',
161-
url: '/data',
162-
body: z.object({ content: z.string() }),
163-
response: z.object({ message: z.string() }),
164-
config: {
165-
description: 'Create data - requires write permission',
166-
tags: ['Protected'],
167-
},
168-
handler: async (request, reply) => {
169-
if (!request.auth) {
170-
return reply.code(401).send({
171-
statusCode: 401,
172-
error: 'Unauthorized',
173-
message: 'Authentication required',
174-
});
175-
}
176-
177-
// Check if user has write permission
178-
if (!request.auth.permissions.includes('write')) {
179-
return reply.code(403).send({
180-
statusCode: 403,
181-
error: 'Forbidden',
182-
message: 'Write permission required',
183-
});
184-
}
185-
186-
return {
187-
message: `Data created by ${request.auth.username}: ${request.body.content}`,
188-
};
189-
},
190-
});
84+
// Protected endpoint with automatic authentication
85+
// Using authenticated: true adds Bearer auth requirement and 401/403 responses
86+
app.createEndpoint({
87+
method: 'GET',
88+
url: '/profile',
89+
authenticated: true,
90+
response: z.object({
91+
userId: z.string(),
92+
username: z.string(),
93+
role: z.string(),
94+
permissions: z.array(z.string()),
95+
}),
96+
config: {
97+
description: 'Get current user profile from auth context',
98+
tags: ['Protected'],
99+
},
100+
handler: async (request) => {
101+
// request.auth is fully typed as AuthContext and guaranteed to exist
102+
// when authenticated: true is set
103+
const { userId, username, role, permissions } = request.auth as AuthContext;
104+
105+
return {
106+
userId,
107+
username,
108+
role,
109+
permissions,
110+
};
111+
},
112+
});
113+
114+
// Admin-only endpoint with role checking
115+
app.createEndpoint({
116+
method: 'DELETE',
117+
url: '/admin/users/:userId',
118+
authenticated: true,
119+
response: z.object({ message: z.string() }),
120+
config: {
121+
description: 'Admin-only endpoint for deleting users',
122+
tags: ['Admin'],
123+
},
124+
handler: async (request, reply) => {
125+
const auth = request.auth as AuthContext;
126+
127+
// Check if user has admin role
128+
if (auth.role !== 'admin') {
129+
return reply.code(403).send({
130+
statusCode: 403,
131+
error: 'Forbidden',
132+
message: 'Admin access required',
133+
});
134+
}
135+
136+
const userId = (request.params as { userId: string }).userId;
137+
return {
138+
message: `User ${userId} deleted by ${auth.username}`,
139+
};
140+
},
141+
});
142+
143+
// Permission-based endpoint
144+
app.createEndpoint({
145+
method: 'POST',
146+
url: '/data',
147+
authenticated: true,
148+
body: z.object({ content: z.string() }),
149+
response: z.object({ message: z.string() }),
150+
config: {
151+
description: 'Create data - requires write permission',
152+
tags: ['Protected'],
153+
},
154+
handler: async (request, reply) => {
155+
const auth = request.auth as AuthContext;
156+
157+
// Check if user has write permission
158+
if (!auth.permissions.includes('write')) {
159+
return reply.code(403).send({
160+
statusCode: 403,
161+
error: 'Forbidden',
162+
message: 'Write permission required',
163+
});
164+
}
165+
166+
return {
167+
message: `Data created by ${auth.username}: ${request.body.content}`,
168+
};
169+
},
191170
});
192171

193172
// Setup graceful shutdown

0 commit comments

Comments
 (0)