adorn-api 1.1.12 → 1.1.14
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 +614 -913
- package/dist/adapter/express/controllers.d.ts +3 -1
- package/dist/adapter/express/controllers.js +4 -1
- package/dist/adapter/express/index.js +5 -1
- package/dist/adapter/express/types.d.ts +3 -0
- package/dist/adapter/fastify/controllers.d.ts +3 -1
- package/dist/adapter/fastify/controllers.js +2 -25
- package/dist/adapter/fastify/index.js +7 -1
- package/dist/adapter/fastify/types.d.ts +3 -0
- package/dist/adapter/metal-orm/index.d.ts +1 -1
- package/dist/adapter/metal-orm/types.d.ts +23 -0
- package/dist/adapter/native/controllers.d.ts +3 -0
- package/dist/adapter/native/controllers.js +2 -25
- package/dist/adapter/native/index.js +14 -1
- package/dist/adapter/native/types.d.ts +3 -0
- package/dist/core/auth.d.ts +33 -3
- package/dist/core/auth.js +74 -22
- package/dist/core/openapi.d.ts +2 -0
- package/dist/core/openapi.js +19 -1
- package/examples/bearer-auth-swagger/app.ts +28 -0
- package/examples/bearer-auth-swagger/auth.controller.ts +45 -0
- package/examples/bearer-auth-swagger/index.ts +20 -0
- package/examples/bearer-auth-swagger/session.dtos.ts +19 -0
- package/package.json +3 -1
- package/src/adapter/express/controllers.ts +23 -18
- package/src/adapter/express/index.ts +12 -1
- package/src/adapter/express/types.ts +13 -10
- package/src/adapter/fastify/controllers.ts +16 -41
- package/src/adapter/fastify/index.ts +27 -13
- package/src/adapter/fastify/types.ts +13 -10
- package/src/adapter/metal-orm/index.ts +3 -0
- package/src/adapter/metal-orm/types.ts +25 -0
- package/src/adapter/native/controllers.ts +16 -41
- package/src/adapter/native/index.ts +28 -15
- package/src/adapter/native/types.ts +13 -10
- package/src/core/auth.ts +134 -56
- package/src/core/openapi.ts +22 -1
- package/tests/e2e/bearer-auth.e2e.test.ts +158 -0
- package/tests/typecheck/query-params.typecheck.ts +42 -0
- package/tests/unit/auth.test.ts +96 -12
- package/tests/unit/openapi-parameters.test.ts +54 -6
- package/tsconfig.typecheck.json +8 -0
- package/vitest.config.ts +47 -7
package/src/core/auth.ts
CHANGED
|
@@ -25,26 +25,44 @@ export interface AuthOptions {
|
|
|
25
25
|
/**
|
|
26
26
|
* Function to extract user from request.
|
|
27
27
|
*/
|
|
28
|
-
export type AuthExtractor = (req: any) => AuthUser | null | Promise<AuthUser | null>;
|
|
28
|
+
export type AuthExtractor = (req: any) => AuthUser | null | Promise<AuthUser | null>;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Function to validate a bearer token and resolve the authenticated user.
|
|
32
|
+
*/
|
|
33
|
+
export type BearerTokenVerifier = (
|
|
34
|
+
token: string,
|
|
35
|
+
req: any
|
|
36
|
+
) => AuthUser | null | Promise<AuthUser | null>;
|
|
29
37
|
|
|
30
38
|
/**
|
|
31
39
|
* Options for creating auth middleware.
|
|
32
40
|
*/
|
|
33
|
-
export interface AuthMiddlewareOptions {
|
|
34
|
-
/** Function to extract user from request */
|
|
35
|
-
extractor: AuthExtractor;
|
|
36
|
-
/** Property name to attach user to request (default: "user") */
|
|
37
|
-
userProperty?: string;
|
|
41
|
+
export interface AuthMiddlewareOptions {
|
|
42
|
+
/** Function to extract user from request */
|
|
43
|
+
extractor: AuthExtractor;
|
|
44
|
+
/** Property name to attach user to request (default: "user") */
|
|
45
|
+
userProperty?: string;
|
|
38
46
|
/** Custom unauthorized response */
|
|
39
47
|
onUnauthorized?: (req: any, res: any) => void;
|
|
40
48
|
/** Custom forbidden response */
|
|
41
|
-
onForbidden?: (req: any, res: any, reason?: string) => void;
|
|
42
|
-
}
|
|
49
|
+
onForbidden?: (req: any, res: any, reason?: string) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Options for built-in Bearer authentication.
|
|
54
|
+
*/
|
|
55
|
+
export interface BearerAuthOptions {
|
|
56
|
+
/** Function to validate token and return user context */
|
|
57
|
+
verifyToken: BearerTokenVerifier;
|
|
58
|
+
/** Property name to attach user to request (default: "user") */
|
|
59
|
+
userProperty?: string;
|
|
60
|
+
}
|
|
43
61
|
|
|
44
62
|
/**
|
|
45
63
|
* Metadata for authentication on routes/controllers.
|
|
46
64
|
*/
|
|
47
|
-
interface AuthMeta {
|
|
65
|
+
export interface AuthMeta {
|
|
48
66
|
/** Whether authentication is required */
|
|
49
67
|
requiresAuth: boolean;
|
|
50
68
|
/** Whether route is public (overrides controller-level auth) */
|
|
@@ -208,11 +226,27 @@ function hasAnyRole(user: AuthUser, roles: string[]): boolean {
|
|
|
208
226
|
/**
|
|
209
227
|
* Checks if user has all required roles.
|
|
210
228
|
*/
|
|
211
|
-
function hasAllRoles(user: AuthUser, roles: string[]): boolean {
|
|
212
|
-
if (!roles.length) return true;
|
|
213
|
-
if (!user.roles?.length) return false;
|
|
214
|
-
return roles.every((role) => user.roles!.includes(role));
|
|
215
|
-
}
|
|
229
|
+
function hasAllRoles(user: AuthUser, roles: string[]): boolean {
|
|
230
|
+
if (!roles.length) return true;
|
|
231
|
+
if (!user.roles?.length) return false;
|
|
232
|
+
return roles.every((role) => user.roles!.includes(role));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Extracts a token from the Authorization: Bearer <token> header.
|
|
237
|
+
*/
|
|
238
|
+
export function extractBearerToken(req: any): string | undefined {
|
|
239
|
+
const headers = req?.headers as Record<string, unknown> | undefined;
|
|
240
|
+
const rawHeader = headers?.authorization ?? headers?.Authorization;
|
|
241
|
+
const header = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader;
|
|
242
|
+
|
|
243
|
+
if (typeof header !== "string") {
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const match = header.match(/^Bearer\s+(\S+)$/i);
|
|
248
|
+
return match?.[1];
|
|
249
|
+
}
|
|
216
250
|
|
|
217
251
|
/**
|
|
218
252
|
* Creates Express middleware for authentication.
|
|
@@ -220,8 +254,8 @@ function hasAllRoles(user: AuthUser, roles: string[]): boolean {
|
|
|
220
254
|
* @param options - Auth middleware options
|
|
221
255
|
* @returns Express middleware function
|
|
222
256
|
*/
|
|
223
|
-
export function createAuthMiddleware(options: AuthMiddlewareOptions) {
|
|
224
|
-
const userProperty = options.userProperty ?? "user";
|
|
257
|
+
export function createAuthMiddleware(options: AuthMiddlewareOptions) {
|
|
258
|
+
const userProperty = options.userProperty ?? "user";
|
|
225
259
|
|
|
226
260
|
return async (req: any, res: any, next: (err?: any) => void): Promise<void> => {
|
|
227
261
|
try {
|
|
@@ -233,8 +267,78 @@ export function createAuthMiddleware(options: AuthMiddlewareOptions) {
|
|
|
233
267
|
} catch (error) {
|
|
234
268
|
next(error);
|
|
235
269
|
}
|
|
236
|
-
};
|
|
237
|
-
}
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Verifies a bearer token from the request and attaches the user when valid.
|
|
275
|
+
*/
|
|
276
|
+
export async function authenticateBearerRequest(
|
|
277
|
+
req: any,
|
|
278
|
+
options: BearerAuthOptions
|
|
279
|
+
): Promise<AuthUser | null> {
|
|
280
|
+
const token = extractBearerToken(req);
|
|
281
|
+
if (!token) {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const user = await options.verifyToken(token, req);
|
|
286
|
+
if (user) {
|
|
287
|
+
(req as Record<string, unknown>)[options.userProperty ?? "user"] = user;
|
|
288
|
+
}
|
|
289
|
+
return user;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Creates middleware that extracts and verifies Authorization bearer tokens.
|
|
294
|
+
*/
|
|
295
|
+
export function createBearerAuthMiddleware(options: BearerAuthOptions) {
|
|
296
|
+
return async (req: any, _res: any, next: (err?: any) => void): Promise<void> => {
|
|
297
|
+
try {
|
|
298
|
+
await authenticateBearerRequest(req, options);
|
|
299
|
+
next();
|
|
300
|
+
} catch (error) {
|
|
301
|
+
next(error);
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Applies route auth metadata to a request that may already have a user attached.
|
|
308
|
+
*/
|
|
309
|
+
export async function assertRouteAuthorized(
|
|
310
|
+
authMeta: AuthMeta | undefined,
|
|
311
|
+
req: any,
|
|
312
|
+
options: { userProperty?: string } = {}
|
|
313
|
+
): Promise<void> {
|
|
314
|
+
if (!authMeta || authMeta.isPublic || !authMeta.requiresAuth) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const userProperty = options.userProperty ?? "user";
|
|
319
|
+
const requestRecord = req as Record<string, unknown>;
|
|
320
|
+
const rawRecord = (req?.raw ?? {}) as Record<string, unknown>;
|
|
321
|
+
const user = (requestRecord[userProperty] ?? rawRecord[userProperty]) as AuthUser | undefined;
|
|
322
|
+
|
|
323
|
+
if (!user) {
|
|
324
|
+
throw new HttpError(401, "Unauthorized");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (authMeta.roles?.length && !hasAnyRole(user, authMeta.roles)) {
|
|
328
|
+
throw new HttpError(403, "Insufficient permissions");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (authMeta.allRoles?.length && !hasAllRoles(user, authMeta.allRoles)) {
|
|
332
|
+
throw new HttpError(403, "Insufficient permissions");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (authMeta.guard) {
|
|
336
|
+
const allowed = await authMeta.guard(user, req);
|
|
337
|
+
if (!allowed) {
|
|
338
|
+
throw new HttpError(403, "Access denied by guard");
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
238
342
|
|
|
239
343
|
/**
|
|
240
344
|
* Creates a route guard middleware that checks auth metadata.
|
|
@@ -243,44 +347,18 @@ export function createAuthMiddleware(options: AuthMiddlewareOptions) {
|
|
|
243
347
|
* @param options - Auth middleware options
|
|
244
348
|
* @returns Express middleware function
|
|
245
349
|
*/
|
|
246
|
-
export function createRouteGuard(
|
|
247
|
-
controller: Constructor,
|
|
248
|
-
handlerName: string | symbol,
|
|
249
|
-
options: { userProperty?: string } = {}
|
|
250
|
-
) {
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
const user = (req as unknown as Record<string, unknown>)[userProperty] as AuthUser | undefined;
|
|
261
|
-
|
|
262
|
-
if (!user) {
|
|
263
|
-
throw new HttpError(401, "Unauthorized");
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
if (authMeta.roles?.length && !hasAnyRole(user, authMeta.roles)) {
|
|
267
|
-
throw new HttpError(403, "Insufficient permissions");
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
if (authMeta.allRoles?.length && !hasAllRoles(user, authMeta.allRoles)) {
|
|
271
|
-
throw new HttpError(403, "Insufficient permissions");
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
if (authMeta.guard) {
|
|
275
|
-
const allowed = await authMeta.guard(user, req);
|
|
276
|
-
if (!allowed) {
|
|
277
|
-
throw new HttpError(403, "Access denied by guard");
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
next();
|
|
282
|
-
};
|
|
283
|
-
}
|
|
350
|
+
export function createRouteGuard(
|
|
351
|
+
controller: Constructor,
|
|
352
|
+
handlerName: string | symbol,
|
|
353
|
+
options: { userProperty?: string } = {}
|
|
354
|
+
) {
|
|
355
|
+
const authMeta = getRouteAuthMeta(controller, handlerName);
|
|
356
|
+
|
|
357
|
+
return async (req: any, _res: any, next: (err?: any) => void): Promise<void> => {
|
|
358
|
+
await assertRouteAuthorized(authMeta, req, options);
|
|
359
|
+
next();
|
|
360
|
+
};
|
|
361
|
+
}
|
|
284
362
|
|
|
285
363
|
/**
|
|
286
364
|
* Helper to get user from request in controllers.
|
package/src/core/openapi.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
buildSchemaFromSource
|
|
7
7
|
} from "./schema-builder";
|
|
8
8
|
import { getAllControllers, getDtoMeta } from "./metadata";
|
|
9
|
+
import { getRouteAuthMeta } from "./auth";
|
|
9
10
|
import type { SchemaNode, SchemaSource } from "./schema";
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -93,6 +94,8 @@ export interface OpenApiDocument {
|
|
|
93
94
|
components: {
|
|
94
95
|
/** Schema definitions */
|
|
95
96
|
schemas: Record<string, JsonSchema>;
|
|
97
|
+
/** Reusable security scheme definitions */
|
|
98
|
+
securitySchemes?: Record<string, unknown>;
|
|
96
99
|
};
|
|
97
100
|
}
|
|
98
101
|
|
|
@@ -106,6 +109,7 @@ export function buildOpenApi(options: OpenApiOptions): OpenApiDocument {
|
|
|
106
109
|
|
|
107
110
|
const controllers = filterControllers(options.controllers);
|
|
108
111
|
const paths: Record<string, Record<string, unknown>> = {};
|
|
112
|
+
let hasBearerAuth = false;
|
|
109
113
|
|
|
110
114
|
for (const controller of controllers) {
|
|
111
115
|
const tagFallback = controller.meta.tags ?? [controller.meta.controller.name];
|
|
@@ -127,6 +131,14 @@ export function buildOpenApi(options: OpenApiOptions): OpenApiDocument {
|
|
|
127
131
|
const requestBody = hasFiles
|
|
128
132
|
? buildMultipartRequestBody(route.files!, route.body, context)
|
|
129
133
|
: buildRequestBody(route.body, context);
|
|
134
|
+
const authMeta = getRouteAuthMeta(controller.meta.controller, route.handlerName);
|
|
135
|
+
const security = authMeta?.requiresAuth && !authMeta.isPublic
|
|
136
|
+
? [{ bearerAuth: [] }]
|
|
137
|
+
: undefined;
|
|
138
|
+
|
|
139
|
+
if (security) {
|
|
140
|
+
hasBearerAuth = true;
|
|
141
|
+
}
|
|
130
142
|
|
|
131
143
|
pathItem[route.httpMethod] = {
|
|
132
144
|
operationId: `${controller.meta.controller.name}.${String(route.handlerName)}`,
|
|
@@ -135,6 +147,7 @@ export function buildOpenApi(options: OpenApiOptions): OpenApiDocument {
|
|
|
135
147
|
tags: route.tags ?? tagFallback,
|
|
136
148
|
parameters: parameters.length ? parameters : undefined,
|
|
137
149
|
requestBody,
|
|
150
|
+
security,
|
|
138
151
|
responses
|
|
139
152
|
};
|
|
140
153
|
}
|
|
@@ -147,7 +160,15 @@ export function buildOpenApi(options: OpenApiOptions): OpenApiDocument {
|
|
|
147
160
|
servers: options.servers,
|
|
148
161
|
paths,
|
|
149
162
|
components: {
|
|
150
|
-
schemas: context.components
|
|
163
|
+
schemas: context.components,
|
|
164
|
+
securitySchemes: hasBearerAuth
|
|
165
|
+
? {
|
|
166
|
+
bearerAuth: {
|
|
167
|
+
type: "http",
|
|
168
|
+
scheme: "bearer"
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
: undefined
|
|
151
172
|
}
|
|
152
173
|
};
|
|
153
174
|
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import request from "supertest";
|
|
3
|
+
import {
|
|
4
|
+
Auth,
|
|
5
|
+
Controller,
|
|
6
|
+
Get,
|
|
7
|
+
Public,
|
|
8
|
+
Roles,
|
|
9
|
+
createExpressApp,
|
|
10
|
+
createFastifyApp,
|
|
11
|
+
createNativeApp,
|
|
12
|
+
shutdownNativeApp,
|
|
13
|
+
type AuthUser
|
|
14
|
+
} from "../../src";
|
|
15
|
+
|
|
16
|
+
@Controller("/bearer")
|
|
17
|
+
class BearerController {
|
|
18
|
+
@Get("/public")
|
|
19
|
+
@Public()
|
|
20
|
+
public() {
|
|
21
|
+
return { message: "public" };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@Get("/protected")
|
|
25
|
+
@Auth()
|
|
26
|
+
protected() {
|
|
27
|
+
return { message: "protected" };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@Get("/admin")
|
|
31
|
+
@Roles("admin")
|
|
32
|
+
admin() {
|
|
33
|
+
return { message: "admin" };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function verifyToken(token: string): AuthUser | null {
|
|
38
|
+
if (token === "valid") {
|
|
39
|
+
return { id: "1", roles: ["user"] };
|
|
40
|
+
}
|
|
41
|
+
if (token === "admin") {
|
|
42
|
+
return { id: "2", roles: ["admin"] };
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function createMockNativeRequest(path: string, token?: string): any {
|
|
48
|
+
return {
|
|
49
|
+
method: "GET",
|
|
50
|
+
url: path,
|
|
51
|
+
headers: token ? { authorization: `Bearer ${token}` } : {}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function createMockNativeResponse(): any {
|
|
56
|
+
const res: any = {
|
|
57
|
+
statusCode: 0,
|
|
58
|
+
headers: {},
|
|
59
|
+
setHeader(name: string, value: string) {
|
|
60
|
+
this.headers[name] = value;
|
|
61
|
+
},
|
|
62
|
+
getHeader(name: string) {
|
|
63
|
+
return this.headers[name];
|
|
64
|
+
},
|
|
65
|
+
end(data: string) {
|
|
66
|
+
this.body = data;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
return res;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe("Bearer auth adapters", () => {
|
|
73
|
+
it("Express blocks protected routes without token", async () => {
|
|
74
|
+
const app = await createExpressApp({
|
|
75
|
+
controllers: [BearerController],
|
|
76
|
+
bearerAuth: { verifyToken }
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const response = await request(app).get("/bearer/protected");
|
|
80
|
+
|
|
81
|
+
expect(response.status).toBe(401);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("Express allows protected routes with a valid bearer token", async () => {
|
|
85
|
+
const app = await createExpressApp({
|
|
86
|
+
controllers: [BearerController],
|
|
87
|
+
bearerAuth: { verifyToken }
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const response = await request(app)
|
|
91
|
+
.get("/bearer/protected")
|
|
92
|
+
.set("Authorization", "Bearer valid");
|
|
93
|
+
|
|
94
|
+
expect(response.status).toBe(200);
|
|
95
|
+
expect(response.body).toEqual({ message: "protected" });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("Express blocks role routes when bearer user lacks role", async () => {
|
|
99
|
+
const app = await createExpressApp({
|
|
100
|
+
controllers: [BearerController],
|
|
101
|
+
bearerAuth: { verifyToken }
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const response = await request(app)
|
|
105
|
+
.get("/bearer/admin")
|
|
106
|
+
.set("Authorization", "Bearer valid");
|
|
107
|
+
|
|
108
|
+
expect(response.status).toBe(403);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("Fastify blocks, allows, and applies roles with bearer tokens", async () => {
|
|
112
|
+
const app = await createFastifyApp({
|
|
113
|
+
controllers: [BearerController],
|
|
114
|
+
bearerAuth: { verifyToken }
|
|
115
|
+
});
|
|
116
|
+
await app.ready();
|
|
117
|
+
|
|
118
|
+
const blocked = await app.inject({ method: "GET", url: "/bearer/protected" });
|
|
119
|
+
const allowed = await app.inject({
|
|
120
|
+
method: "GET",
|
|
121
|
+
url: "/bearer/protected",
|
|
122
|
+
headers: { authorization: "Bearer valid" }
|
|
123
|
+
});
|
|
124
|
+
const forbidden = await app.inject({
|
|
125
|
+
method: "GET",
|
|
126
|
+
url: "/bearer/admin",
|
|
127
|
+
headers: { authorization: "Bearer valid" }
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(blocked.statusCode).toBe(401);
|
|
131
|
+
expect(allowed.statusCode).toBe(200);
|
|
132
|
+
expect(allowed.json()).toEqual({ message: "protected" });
|
|
133
|
+
expect(forbidden.statusCode).toBe(403);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("Native blocks, allows, and applies roles with bearer tokens", async () => {
|
|
137
|
+
const app = await createNativeApp({
|
|
138
|
+
controllers: [BearerController],
|
|
139
|
+
bearerAuth: { verifyToken }
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const blocked = createMockNativeResponse();
|
|
143
|
+
await app.handle(createMockNativeRequest("/bearer/protected"), blocked);
|
|
144
|
+
|
|
145
|
+
const allowed = createMockNativeResponse();
|
|
146
|
+
await app.handle(createMockNativeRequest("/bearer/protected", "valid"), allowed);
|
|
147
|
+
|
|
148
|
+
const forbidden = createMockNativeResponse();
|
|
149
|
+
await app.handle(createMockNativeRequest("/bearer/admin", "valid"), forbidden);
|
|
150
|
+
|
|
151
|
+
expect(blocked.statusCode).toBe(401);
|
|
152
|
+
expect(allowed.statusCode).toBe(200);
|
|
153
|
+
expect(JSON.parse(allowed.body)).toEqual({ message: "protected" });
|
|
154
|
+
expect(forbidden.statusCode).toBe(403);
|
|
155
|
+
|
|
156
|
+
await shutdownNativeApp();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PagedQueryParams,
|
|
3
|
+
PaginationQueryParams,
|
|
4
|
+
SortDirection,
|
|
5
|
+
SortingQueryParams
|
|
6
|
+
} from "../../src/index";
|
|
7
|
+
|
|
8
|
+
type Assert<T extends true> = T;
|
|
9
|
+
type IsEqual<A, B> =
|
|
10
|
+
(<T>() => T extends A ? 1 : 2) extends
|
|
11
|
+
(<T>() => T extends B ? 1 : 2)
|
|
12
|
+
? true
|
|
13
|
+
: false;
|
|
14
|
+
|
|
15
|
+
type _SortDirectionMatchesPublicType = Assert<
|
|
16
|
+
IsEqual<SortingQueryParams["sortDirection"], SortDirection | undefined>
|
|
17
|
+
>;
|
|
18
|
+
|
|
19
|
+
const paginationOnly: PaginationQueryParams = {
|
|
20
|
+
page: 1,
|
|
21
|
+
pageSize: 25
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const sortingOnly: SortingQueryParams = {
|
|
25
|
+
sortBy: "createdAt",
|
|
26
|
+
sortDirection: "desc"
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const pagedWithSorting: PagedQueryParams = {
|
|
30
|
+
page: paginationOnly.page,
|
|
31
|
+
pageSize: paginationOnly.pageSize,
|
|
32
|
+
sortBy: sortingOnly.sortBy,
|
|
33
|
+
sortDirection: "asc"
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// @ts-expect-error sortDirection accepts only "asc" | "desc"
|
|
37
|
+
const invalidSorting: SortingQueryParams = { sortDirection: "ASC" };
|
|
38
|
+
|
|
39
|
+
void paginationOnly;
|
|
40
|
+
void sortingOnly;
|
|
41
|
+
void pagedWithSorting;
|
|
42
|
+
void invalidSorting;
|
package/tests/unit/auth.test.ts
CHANGED
|
@@ -5,14 +5,16 @@ import {
|
|
|
5
5
|
Roles,
|
|
6
6
|
AllRoles,
|
|
7
7
|
Public,
|
|
8
|
-
getRouteAuthMeta,
|
|
9
|
-
getControllerAuthMeta,
|
|
10
|
-
createAuthMiddleware,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
8
|
+
getRouteAuthMeta,
|
|
9
|
+
getControllerAuthMeta,
|
|
10
|
+
createAuthMiddleware,
|
|
11
|
+
createBearerAuthMiddleware,
|
|
12
|
+
createRouteGuard,
|
|
13
|
+
extractBearerToken,
|
|
14
|
+
getUser,
|
|
15
|
+
requireUser,
|
|
16
|
+
type AuthUser
|
|
17
|
+
} from "../../src/core/auth";
|
|
16
18
|
import { Controller } from "../../src/core/decorators";
|
|
17
19
|
import { HttpError } from "../../src/core/errors";
|
|
18
20
|
|
|
@@ -176,7 +178,7 @@ describe("getRouteAuthMeta", () => {
|
|
|
176
178
|
});
|
|
177
179
|
});
|
|
178
180
|
|
|
179
|
-
describe("createAuthMiddleware", () => {
|
|
181
|
+
describe("createAuthMiddleware", () => {
|
|
180
182
|
it("extracts user and attaches to request", async () => {
|
|
181
183
|
const user: AuthUser = { id: "123", roles: ["user"] };
|
|
182
184
|
const middleware = createAuthMiddleware({
|
|
@@ -238,9 +240,91 @@ describe("createAuthMiddleware", () => {
|
|
|
238
240
|
|
|
239
241
|
expect((req as unknown as Record<string, unknown>).user).toBe(user);
|
|
240
242
|
});
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
describe("
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe("extractBearerToken", () => {
|
|
246
|
+
it("returns undefined when authorization header is missing", () => {
|
|
247
|
+
expect(extractBearerToken(createMockRequest())).toBeUndefined();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("extracts token from Bearer authorization header", () => {
|
|
251
|
+
const req = createMockRequest({ headers: { authorization: "Bearer abc123" } });
|
|
252
|
+
expect(extractBearerToken(req)).toBe("abc123");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("extracts token from case-insensitive bearer scheme", () => {
|
|
256
|
+
const req = createMockRequest({ headers: { authorization: "bearer abc123" } });
|
|
257
|
+
expect(extractBearerToken(req)).toBe("abc123");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("returns undefined for invalid authorization formats", () => {
|
|
261
|
+
expect(extractBearerToken(createMockRequest({ headers: { authorization: "Basic abc123" } }))).toBeUndefined();
|
|
262
|
+
expect(extractBearerToken(createMockRequest({ headers: { authorization: "Bearer" } }))).toBeUndefined();
|
|
263
|
+
expect(extractBearerToken(createMockRequest({ headers: { authorization: "Bearer abc def" } }))).toBeUndefined();
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe("createBearerAuthMiddleware", () => {
|
|
268
|
+
it("attaches user for a valid bearer token", async () => {
|
|
269
|
+
const user: AuthUser = { id: "bearer-user", roles: ["user"] };
|
|
270
|
+
const middleware = createBearerAuthMiddleware({
|
|
271
|
+
verifyToken: vi.fn().mockResolvedValue(user)
|
|
272
|
+
});
|
|
273
|
+
const req = createMockRequest({ headers: { authorization: "Bearer valid" } });
|
|
274
|
+
const res = createMockResponse();
|
|
275
|
+
const next = vi.fn();
|
|
276
|
+
|
|
277
|
+
await middleware(req, res, next);
|
|
278
|
+
|
|
279
|
+
expect((req as unknown as Record<string, unknown>).user).toBe(user);
|
|
280
|
+
expect(next).toHaveBeenCalledWith();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("does not attach user for an invalid bearer token", async () => {
|
|
284
|
+
const middleware = createBearerAuthMiddleware({
|
|
285
|
+
verifyToken: vi.fn().mockResolvedValue(null)
|
|
286
|
+
});
|
|
287
|
+
const req = createMockRequest({ headers: { authorization: "Bearer invalid" } });
|
|
288
|
+
const res = createMockResponse();
|
|
289
|
+
const next = vi.fn();
|
|
290
|
+
|
|
291
|
+
await middleware(req, res, next);
|
|
292
|
+
|
|
293
|
+
expect((req as unknown as Record<string, unknown>).user).toBeUndefined();
|
|
294
|
+
expect(next).toHaveBeenCalledWith();
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("passes verifier errors to next", async () => {
|
|
298
|
+
const error = new Error("verifier failed");
|
|
299
|
+
const middleware = createBearerAuthMiddleware({
|
|
300
|
+
verifyToken: vi.fn().mockRejectedValue(error)
|
|
301
|
+
});
|
|
302
|
+
const req = createMockRequest({ headers: { authorization: "Bearer bad" } });
|
|
303
|
+
const res = createMockResponse();
|
|
304
|
+
const next = vi.fn();
|
|
305
|
+
|
|
306
|
+
await middleware(req, res, next);
|
|
307
|
+
|
|
308
|
+
expect(next).toHaveBeenCalledWith(error);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("uses custom user property", async () => {
|
|
312
|
+
const user: AuthUser = { id: "custom" };
|
|
313
|
+
const middleware = createBearerAuthMiddleware({
|
|
314
|
+
userProperty: "currentUser",
|
|
315
|
+
verifyToken: vi.fn().mockResolvedValue(user)
|
|
316
|
+
});
|
|
317
|
+
const req = createMockRequest({ headers: { authorization: "Bearer valid" } });
|
|
318
|
+
const res = createMockResponse();
|
|
319
|
+
const next = vi.fn();
|
|
320
|
+
|
|
321
|
+
await middleware(req, res, next);
|
|
322
|
+
|
|
323
|
+
expect((req as unknown as Record<string, unknown>).currentUser).toBe(user);
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
describe("createRouteGuard", () => {
|
|
244
328
|
it("allows public routes without user", async () => {
|
|
245
329
|
@Controller("/api")
|
|
246
330
|
class ApiController {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it, beforeEach } from "vitest";
|
|
2
|
-
import { t } from "../../src/core/schema";
|
|
3
|
-
import { registerController } from "../../src/core/metadata";
|
|
4
|
-
import { buildOpenApi } from "../../src/core/openapi";
|
|
5
|
-
import { createInputCoercer } from "../../src/adapter/express/coercion";
|
|
2
|
+
import { t } from "../../src/core/schema";
|
|
3
|
+
import { registerController } from "../../src/core/metadata";
|
|
4
|
+
import { buildOpenApi } from "../../src/core/openapi";
|
|
5
|
+
import { createInputCoercer } from "../../src/adapter/express/coercion";
|
|
6
|
+
import { Auth, Controller, Get, Public } from "../../src";
|
|
6
7
|
|
|
7
8
|
describe("OpenAPI query parameter serialization", () => {
|
|
8
9
|
class QueryArrayController {}
|
|
@@ -74,7 +75,7 @@ describe("OpenAPI query parameter serialization", () => {
|
|
|
74
75
|
});
|
|
75
76
|
});
|
|
76
77
|
|
|
77
|
-
describe("Query array coercion – CSV support", () => {
|
|
78
|
+
describe("Query array coercion – CSV support", () => {
|
|
78
79
|
it("?ids=1&ids=2 -> [1,2] via repeated keys", () => {
|
|
79
80
|
const coerce = createInputCoercer(
|
|
80
81
|
{ schema: { kind: "object", properties: { ids: t.array(t.integer()) } } },
|
|
@@ -94,4 +95,51 @@ describe("Query array coercion – CSV support", () => {
|
|
|
94
95
|
const result = coerce({ ids: "1,2" });
|
|
95
96
|
expect(result.ids).toEqual([1, 2]);
|
|
96
97
|
});
|
|
97
|
-
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("OpenAPI bearer auth security", () => {
|
|
101
|
+
@Controller("/secure-docs")
|
|
102
|
+
class SecureDocsController {
|
|
103
|
+
@Get("/public")
|
|
104
|
+
@Public()
|
|
105
|
+
publicRoute() {
|
|
106
|
+
return {};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
@Get("/protected")
|
|
110
|
+
@Auth()
|
|
111
|
+
protectedRoute() {
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
it("adds bearerAuth security scheme when protected routes exist", () => {
|
|
117
|
+
new SecureDocsController();
|
|
118
|
+
|
|
119
|
+
const doc = buildOpenApi({
|
|
120
|
+
info: { title: "test", version: "1.0.0" },
|
|
121
|
+
controllers: [SecureDocsController]
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(doc.components.securitySchemes).toEqual({
|
|
125
|
+
bearerAuth: {
|
|
126
|
+
type: "http",
|
|
127
|
+
scheme: "bearer"
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("adds security only to protected operations", () => {
|
|
133
|
+
new SecureDocsController();
|
|
134
|
+
|
|
135
|
+
const doc = buildOpenApi({
|
|
136
|
+
info: { title: "test", version: "1.0.0" },
|
|
137
|
+
controllers: [SecureDocsController]
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
expect((doc.paths["/secure-docs/protected"] as any).get.security).toEqual([
|
|
141
|
+
{ bearerAuth: [] }
|
|
142
|
+
]);
|
|
143
|
+
expect((doc.paths["/secure-docs/public"] as any).get.security).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
});
|