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.
Files changed (43) hide show
  1. package/README.md +614 -913
  2. package/dist/adapter/express/controllers.d.ts +3 -1
  3. package/dist/adapter/express/controllers.js +4 -1
  4. package/dist/adapter/express/index.js +5 -1
  5. package/dist/adapter/express/types.d.ts +3 -0
  6. package/dist/adapter/fastify/controllers.d.ts +3 -1
  7. package/dist/adapter/fastify/controllers.js +2 -25
  8. package/dist/adapter/fastify/index.js +7 -1
  9. package/dist/adapter/fastify/types.d.ts +3 -0
  10. package/dist/adapter/metal-orm/index.d.ts +1 -1
  11. package/dist/adapter/metal-orm/types.d.ts +23 -0
  12. package/dist/adapter/native/controllers.d.ts +3 -0
  13. package/dist/adapter/native/controllers.js +2 -25
  14. package/dist/adapter/native/index.js +14 -1
  15. package/dist/adapter/native/types.d.ts +3 -0
  16. package/dist/core/auth.d.ts +33 -3
  17. package/dist/core/auth.js +74 -22
  18. package/dist/core/openapi.d.ts +2 -0
  19. package/dist/core/openapi.js +19 -1
  20. package/examples/bearer-auth-swagger/app.ts +28 -0
  21. package/examples/bearer-auth-swagger/auth.controller.ts +45 -0
  22. package/examples/bearer-auth-swagger/index.ts +20 -0
  23. package/examples/bearer-auth-swagger/session.dtos.ts +19 -0
  24. package/package.json +3 -1
  25. package/src/adapter/express/controllers.ts +23 -18
  26. package/src/adapter/express/index.ts +12 -1
  27. package/src/adapter/express/types.ts +13 -10
  28. package/src/adapter/fastify/controllers.ts +16 -41
  29. package/src/adapter/fastify/index.ts +27 -13
  30. package/src/adapter/fastify/types.ts +13 -10
  31. package/src/adapter/metal-orm/index.ts +3 -0
  32. package/src/adapter/metal-orm/types.ts +25 -0
  33. package/src/adapter/native/controllers.ts +16 -41
  34. package/src/adapter/native/index.ts +28 -15
  35. package/src/adapter/native/types.ts +13 -10
  36. package/src/core/auth.ts +134 -56
  37. package/src/core/openapi.ts +22 -1
  38. package/tests/e2e/bearer-auth.e2e.test.ts +158 -0
  39. package/tests/typecheck/query-params.typecheck.ts +42 -0
  40. package/tests/unit/auth.test.ts +96 -12
  41. package/tests/unit/openapi-parameters.test.ts +54 -6
  42. package/tsconfig.typecheck.json +8 -0
  43. 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 userProperty = options.userProperty ?? "user";
252
- const authMeta = getRouteAuthMeta(controller, handlerName);
253
-
254
- return async (req: any, res: any, next: (err?: any) => void): Promise<void> => {
255
- if (!authMeta || authMeta.isPublic || !authMeta.requiresAuth) {
256
- next();
257
- return;
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.
@@ -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;
@@ -5,14 +5,16 @@ import {
5
5
  Roles,
6
6
  AllRoles,
7
7
  Public,
8
- getRouteAuthMeta,
9
- getControllerAuthMeta,
10
- createAuthMiddleware,
11
- createRouteGuard,
12
- getUser,
13
- requireUser,
14
- type AuthUser
15
- } from "../../src/core/auth";
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("createRouteGuard", () => {
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
+ });