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
|
@@ -8,4 +8,6 @@ import type { InputCoercionSetting, MultipartOptions, ValidationOptions } from "
|
|
|
8
8
|
* @param inputCoercion - Input coercion setting
|
|
9
9
|
* @param multipart - Multipart file upload configuration
|
|
10
10
|
*/
|
|
11
|
-
export declare function attachControllers(app: Express, controllers: Constructor[], inputCoercion?: InputCoercionSetting, multipart?: boolean | MultipartOptions, validation?: boolean | ValidationOptions
|
|
11
|
+
export declare function attachControllers(app: Express, controllers: Constructor[], inputCoercion?: InputCoercionSetting, multipart?: boolean | MultipartOptions, validation?: boolean | ValidationOptions, auth?: {
|
|
12
|
+
userProperty?: string;
|
|
13
|
+
}): Promise<void>;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.attachControllers = attachControllers;
|
|
4
4
|
const metadata_1 = require("../../core/metadata");
|
|
5
|
+
const auth_1 = require("../../core/auth");
|
|
5
6
|
const errors_1 = require("../../core/errors");
|
|
6
7
|
const response_1 = require("../../core/response");
|
|
7
8
|
const coercion_1 = require("./coercion");
|
|
@@ -18,7 +19,7 @@ const validation_errors_1 = require("../../core/validation-errors");
|
|
|
18
19
|
* @param inputCoercion - Input coercion setting
|
|
19
20
|
* @param multipart - Multipart file upload configuration
|
|
20
21
|
*/
|
|
21
|
-
async function attachControllers(app, controllers, inputCoercion = "safe", multipart, validation) {
|
|
22
|
+
async function attachControllers(app, controllers, inputCoercion = "safe", multipart, validation, auth) {
|
|
22
23
|
const multipartOptions = (0, multipart_1.normalizeMultipartOptions)(multipart);
|
|
23
24
|
for (const controller of controllers) {
|
|
24
25
|
const meta = (0, metadata_1.getControllerMeta)(controller);
|
|
@@ -51,9 +52,11 @@ async function attachControllers(app, controllers, inputCoercion = "safe", multi
|
|
|
51
52
|
}
|
|
52
53
|
// Determine if validation is enabled for this route
|
|
53
54
|
const isValidationEnabled = validation !== false && validation?.enabled !== false;
|
|
55
|
+
const authMeta = (0, auth_1.getRouteAuthMeta)(controller, route.handlerName);
|
|
54
56
|
// Main route handler
|
|
55
57
|
const routeHandler = async (req, res, next) => {
|
|
56
58
|
try {
|
|
59
|
+
await (0, auth_1.assertRouteAuthorized)(authMeta, req, auth);
|
|
57
60
|
const files = (0, multipart_1.extractFiles)(req);
|
|
58
61
|
// Create context
|
|
59
62
|
const ctx = {
|
|
@@ -25,6 +25,7 @@ const cors_1 = require("./cors");
|
|
|
25
25
|
const controllers_1 = require("./controllers");
|
|
26
26
|
const openapi_1 = require("./openapi");
|
|
27
27
|
const lifecycle_1 = require("../../core/lifecycle");
|
|
28
|
+
const auth_1 = require("../../core/auth");
|
|
28
29
|
__exportStar(require("./types"), exports);
|
|
29
30
|
var cors_2 = require("./cors");
|
|
30
31
|
Object.defineProperty(exports, "attachCors", { enumerable: true, get: function () { return cors_2.attachCors; } });
|
|
@@ -45,8 +46,11 @@ async function createExpressApp(options) {
|
|
|
45
46
|
if (options.jsonBody ?? true) {
|
|
46
47
|
app.use(express_1.default.json({ limit: options.jsonLimit }));
|
|
47
48
|
}
|
|
49
|
+
if (options.bearerAuth) {
|
|
50
|
+
app.use((0, auth_1.createBearerAuthMiddleware)(options.bearerAuth));
|
|
51
|
+
}
|
|
48
52
|
const inputCoercion = options.inputCoercion ?? "safe";
|
|
49
|
-
await (0, controllers_1.attachControllers)(app, options.controllers, inputCoercion, options.multipart, options.validation);
|
|
53
|
+
await (0, controllers_1.attachControllers)(app, options.controllers, inputCoercion, options.multipart, options.validation, { userProperty: options.bearerAuth?.userProperty });
|
|
50
54
|
if (options.openApi) {
|
|
51
55
|
(0, openapi_1.attachOpenApi)(app, options.controllers, options.openApi);
|
|
52
56
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Constructor, RequestContext as CoreRequestContext, UploadedFileInfo } from "../../core/types";
|
|
2
2
|
import type { OpenApiInfo, OpenApiServer } from "../../core/openapi";
|
|
3
|
+
import type { BearerAuthOptions } from "../../core/auth";
|
|
3
4
|
export { UploadedFileInfo };
|
|
4
5
|
/**
|
|
5
6
|
* Request context provided to route handlers.
|
|
@@ -94,6 +95,8 @@ export interface ExpressAdapterOptions {
|
|
|
94
95
|
inputCoercion?: InputCoercionSetting;
|
|
95
96
|
/** CORS configuration. Set to true for permissive defaults, or provide options. */
|
|
96
97
|
cors?: boolean | CorsOptions;
|
|
98
|
+
/** Built-in bearer token authentication for protected routes. */
|
|
99
|
+
bearerAuth?: BearerAuthOptions;
|
|
97
100
|
/** Multipart file upload configuration. Set to true for defaults, or provide options. */
|
|
98
101
|
multipart?: boolean | MultipartOptions;
|
|
99
102
|
/** Validation configuration. Set to false to disable validation, or provide options. */
|
|
@@ -4,4 +4,6 @@ import type { InputCoercionSetting, MultipartOptions, ValidationOptions } from "
|
|
|
4
4
|
/**
|
|
5
5
|
* Attaches controllers to a Fastify application.
|
|
6
6
|
*/
|
|
7
|
-
export declare function attachControllers(app: FastifyInstance, controllers: Constructor[], inputCoercion?: InputCoercionSetting, multipart?: boolean | MultipartOptions, validation?: boolean | ValidationOptions
|
|
7
|
+
export declare function attachControllers(app: FastifyInstance, controllers: Constructor[], inputCoercion?: InputCoercionSetting, multipart?: boolean | MultipartOptions, validation?: boolean | ValidationOptions, auth?: {
|
|
8
|
+
userProperty?: string;
|
|
9
|
+
}): Promise<void>;
|
|
@@ -15,7 +15,7 @@ const validation_errors_1 = require("../../core/validation-errors");
|
|
|
15
15
|
/**
|
|
16
16
|
* Attaches controllers to a Fastify application.
|
|
17
17
|
*/
|
|
18
|
-
async function attachControllers(app, controllers, inputCoercion = "safe", multipart, validation) {
|
|
18
|
+
async function attachControllers(app, controllers, inputCoercion = "safe", multipart, validation, auth) {
|
|
19
19
|
const multipartOptions = (0, multipart_1.normalizeMultipartOptions)(multipart);
|
|
20
20
|
for (const controller of controllers) {
|
|
21
21
|
const meta = (0, metadata_1.getControllerMeta)(controller);
|
|
@@ -48,30 +48,7 @@ async function attachControllers(app, controllers, inputCoercion = "safe", multi
|
|
|
48
48
|
handler: async (req, reply) => {
|
|
49
49
|
try {
|
|
50
50
|
// Apply auth guard if metadata exists
|
|
51
|
-
|
|
52
|
-
const user = req.user || req.raw.user;
|
|
53
|
-
if (!user) {
|
|
54
|
-
throw new errors_1.HttpError(401, "Unauthorized");
|
|
55
|
-
}
|
|
56
|
-
if (authMeta.roles?.length) {
|
|
57
|
-
const hasRole = authMeta.roles.some((role) => user.roles?.includes(role));
|
|
58
|
-
if (!hasRole) {
|
|
59
|
-
throw new errors_1.HttpError(403, "Insufficient permissions");
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
if (authMeta.allRoles?.length) {
|
|
63
|
-
const hasAllRoles = authMeta.allRoles.every((role) => user.roles?.includes(role));
|
|
64
|
-
if (!hasAllRoles) {
|
|
65
|
-
throw new errors_1.HttpError(403, "Insufficient permissions");
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
if (authMeta.guard) {
|
|
69
|
-
const allowed = await authMeta.guard(user, req);
|
|
70
|
-
if (!allowed) {
|
|
71
|
-
throw new errors_1.HttpError(403, "Access denied by guard");
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
51
|
+
await (0, auth_1.assertRouteAuthorized)(authMeta, req, auth);
|
|
75
52
|
let files = undefined;
|
|
76
53
|
if (multipartOptions && (0, multipart_1.hasFileUploads)(route.files)) {
|
|
77
54
|
files = await (0, multipart_1.extractFiles)(req);
|
|
@@ -24,6 +24,7 @@ const fastify_1 = __importDefault(require("fastify"));
|
|
|
24
24
|
const controllers_1 = require("./controllers");
|
|
25
25
|
const openapi_1 = require("./openapi");
|
|
26
26
|
const lifecycle_1 = require("../../core/lifecycle");
|
|
27
|
+
const auth_1 = require("../../core/auth");
|
|
27
28
|
const cors_1 = __importDefault(require("@fastify/cors"));
|
|
28
29
|
const multipart_1 = __importDefault(require("@fastify/multipart"));
|
|
29
30
|
__exportStar(require("./types"), exports);
|
|
@@ -50,8 +51,13 @@ async function createFastifyApp(options) {
|
|
|
50
51
|
}
|
|
51
52
|
});
|
|
52
53
|
}
|
|
54
|
+
if (options.bearerAuth) {
|
|
55
|
+
app.addHook("preHandler", async (req) => {
|
|
56
|
+
await (0, auth_1.authenticateBearerRequest)(req, options.bearerAuth);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
53
59
|
const inputCoercion = options.inputCoercion ?? "safe";
|
|
54
|
-
await (0, controllers_1.attachControllers)(app, options.controllers, inputCoercion, options.multipart, options.validation);
|
|
60
|
+
await (0, controllers_1.attachControllers)(app, options.controllers, inputCoercion, options.multipart, options.validation, { userProperty: options.bearerAuth?.userProperty });
|
|
55
61
|
if (options.openApi) {
|
|
56
62
|
(0, openapi_1.attachOpenApi)(app, options.controllers, options.openApi);
|
|
57
63
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Constructor, RequestContext as CoreRequestContext, UploadedFileInfo } from "../../core/types";
|
|
2
2
|
import type { OpenApiInfo, OpenApiServer } from "../../core/openapi";
|
|
3
|
+
import type { BearerAuthOptions } from "../../core/auth";
|
|
3
4
|
/**
|
|
4
5
|
* Request context provided to Fastify route handlers.
|
|
5
6
|
*/
|
|
@@ -93,6 +94,8 @@ export interface FastifyAdapterOptions {
|
|
|
93
94
|
inputCoercion?: InputCoercionSetting;
|
|
94
95
|
/** CORS configuration. Set to true for permissive defaults, or provide options. */
|
|
95
96
|
cors?: boolean | CorsOptions;
|
|
97
|
+
/** Built-in bearer token authentication for protected routes. */
|
|
98
|
+
bearerAuth?: BearerAuthOptions;
|
|
96
99
|
/** Multipart file upload configuration. Set to true for defaults, or provide options. */
|
|
97
100
|
multipart?: boolean | MultipartOptions;
|
|
98
101
|
/** Validation configuration. Set to false to disable validation, or provide options. */
|
|
@@ -11,4 +11,4 @@ export { createMetalDtoOverrides, type CreateMetalDtoOverridesOptions } from "./
|
|
|
11
11
|
export { createErrorDtoClass, StandardErrorDto, SimpleErrorDto, BasicErrorDto } from "./error-dtos";
|
|
12
12
|
export { withSession, parseIdOrThrow, compactUpdates, applyInput, getEntityOrThrow } from "./utils";
|
|
13
13
|
export { validateEntityMetadata, hasValidEntityMetadata } from "./field-builder";
|
|
14
|
-
export type { MetalDtoMode, MetalDtoOptions, MetalDtoTarget, PaginationConfig, PaginationOptions, ParsedPagination, Filter, FilterMapping, FilterFieldMapping, FilterFieldPath, FilterFieldPathArray, FilterFieldInput, RelationQuantifier, ParseFilterOptions, ParseSortOptions, ParsedSort, SortDirection, CrudListSortTerm, RunPagedListOptions, ExecuteCrudListOptions, CrudPagedResponse, ListConfig, PagedQueryDtoOptions, PagedResponseDtoOptions, PagedFilterQueryDtoOptions, FilterFieldDef, MetalCrudQueryFilterDef, MetalCrudSortableColumns, MetalCrudOptionsQueryOptions, MetalCrudQueryOptions, MetalCrudStandardErrorsOptions, MetalCrudDtoOptions, MetalCrudDtoClassOptions, MetalCrudDtoDecorators, MetalCrudDtoClasses, MetalCrudDtoClassNameKey, MetalCrudDtoClassNames, CrudControllerService, CrudControllerServiceInput, CreateCrudControllerOptions, RouteErrorsDecorator, NestedCreateDtoOptions, MetalTreeDtoClassOptions, MetalTreeDtoClasses, MetalTreeDtoClassNames, MetalTreeListEntryOptions, ErrorDtoOptions, CreateSessionFn } from "./types";
|
|
14
|
+
export type { MetalDtoMode, MetalDtoOptions, MetalDtoTarget, PaginationConfig, PaginationOptions, ParsedPagination, PaginationQueryParams, Filter, FilterMapping, FilterFieldMapping, FilterFieldPath, FilterFieldPathArray, FilterFieldInput, RelationQuantifier, ParseFilterOptions, ParseSortOptions, ParsedSort, SortDirection, SortingQueryParams, PagedQueryParams, CrudListSortTerm, RunPagedListOptions, ExecuteCrudListOptions, CrudPagedResponse, ListConfig, PagedQueryDtoOptions, PagedResponseDtoOptions, PagedFilterQueryDtoOptions, FilterFieldDef, MetalCrudQueryFilterDef, MetalCrudSortableColumns, MetalCrudOptionsQueryOptions, MetalCrudQueryOptions, MetalCrudStandardErrorsOptions, MetalCrudDtoOptions, MetalCrudDtoClassOptions, MetalCrudDtoDecorators, MetalCrudDtoClasses, MetalCrudDtoClassNameKey, MetalCrudDtoClassNames, CrudControllerService, CrudControllerServiceInput, CreateCrudControllerOptions, RouteErrorsDecorator, NestedCreateDtoOptions, MetalTreeDtoClassOptions, MetalTreeDtoClasses, MetalTreeDtoClassNames, MetalTreeListEntryOptions, ErrorDtoOptions, CreateSessionFn } from "./types";
|
|
@@ -56,6 +56,15 @@ export interface ParsedPagination {
|
|
|
56
56
|
/** Page size */
|
|
57
57
|
pageSize: number;
|
|
58
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Pagination query params for consumer-side TypeScript interfaces.
|
|
61
|
+
*/
|
|
62
|
+
export interface PaginationQueryParams {
|
|
63
|
+
/** Page number */
|
|
64
|
+
page?: number;
|
|
65
|
+
/** Page size */
|
|
66
|
+
pageSize?: number;
|
|
67
|
+
}
|
|
59
68
|
/**
|
|
60
69
|
* Filter field mapping.
|
|
61
70
|
*/
|
|
@@ -108,6 +117,20 @@ export interface ParseFilterOptions<T = Record<string, unknown>> {
|
|
|
108
117
|
* Sort direction.
|
|
109
118
|
*/
|
|
110
119
|
export type SortDirection = "asc" | "desc";
|
|
120
|
+
/**
|
|
121
|
+
* Sorting query params for consumer-side TypeScript interfaces.
|
|
122
|
+
*/
|
|
123
|
+
export interface SortingQueryParams {
|
|
124
|
+
/** Requested sort key */
|
|
125
|
+
sortBy?: string;
|
|
126
|
+
/** Sort direction */
|
|
127
|
+
sortDirection?: SortDirection;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Combined pagination + sorting query params.
|
|
131
|
+
*/
|
|
132
|
+
export interface PagedQueryParams extends PaginationQueryParams, SortingQueryParams {
|
|
133
|
+
}
|
|
111
134
|
/**
|
|
112
135
|
* Sort parsing options.
|
|
113
136
|
*/
|
|
@@ -34,7 +34,7 @@ async function registerControllers(router, controllers) {
|
|
|
34
34
|
*/
|
|
35
35
|
async function dispatchRequest(req, res, match, options) {
|
|
36
36
|
const { controller: instance, route, params: rawParams } = match;
|
|
37
|
-
const { inputCoercion, validation, body: rawBody, query: rawQuery } = options;
|
|
37
|
+
const { inputCoercion, validation, body: rawBody, query: rawQuery, auth } = options;
|
|
38
38
|
const handler = instance[route.handlerName];
|
|
39
39
|
if (typeof handler !== "function") {
|
|
40
40
|
throw new Error(`Handler "${String(route.handlerName)}" is not a function.`);
|
|
@@ -52,30 +52,7 @@ async function dispatchRequest(req, res, match, options) {
|
|
|
52
52
|
const authMeta = (0, auth_1.getRouteAuthMeta)(instance.constructor, route.handlerName);
|
|
53
53
|
try {
|
|
54
54
|
// Apply auth guard if metadata exists
|
|
55
|
-
|
|
56
|
-
const user = req.user;
|
|
57
|
-
if (!user) {
|
|
58
|
-
throw new errors_1.HttpError(401, "Unauthorized");
|
|
59
|
-
}
|
|
60
|
-
if (authMeta.roles?.length) {
|
|
61
|
-
const hasRole = authMeta.roles.some((role) => user.roles?.includes(role));
|
|
62
|
-
if (!hasRole) {
|
|
63
|
-
throw new errors_1.HttpError(403, "Insufficient permissions");
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
if (authMeta.allRoles?.length) {
|
|
67
|
-
const hasAllRoles = authMeta.allRoles.every((role) => user.roles?.includes(role));
|
|
68
|
-
if (!hasAllRoles) {
|
|
69
|
-
throw new errors_1.HttpError(403, "Insufficient permissions");
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
if (authMeta.guard) {
|
|
73
|
-
const allowed = await authMeta.guard(user, req);
|
|
74
|
-
if (!allowed) {
|
|
75
|
-
throw new errors_1.HttpError(403, "Access denied by guard");
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
55
|
+
await (0, auth_1.assertRouteAuthorized)(authMeta, req, auth);
|
|
79
56
|
const body = (coerceBody && rawBody) ? coerceBody(rawBody) : rawBody;
|
|
80
57
|
const query = (coerceQuery && rawQuery) ? coerceQuery(rawQuery) : rawQuery;
|
|
81
58
|
const params = (coerceParams && rawParams) ? coerceParams(rawParams) : rawParams;
|
|
@@ -21,6 +21,7 @@ const node_http_1 = require("node:http");
|
|
|
21
21
|
const controllers_1 = require("./controllers");
|
|
22
22
|
const openapi_1 = require("./openapi");
|
|
23
23
|
const lifecycle_1 = require("../../core/lifecycle");
|
|
24
|
+
const auth_1 = require("../../core/auth");
|
|
24
25
|
const router_1 = require("./router");
|
|
25
26
|
__exportStar(require("./types"), exports);
|
|
26
27
|
var controllers_2 = require("./controllers");
|
|
@@ -48,6 +49,17 @@ async function createNativeApp(options) {
|
|
|
48
49
|
res.end(JSON.stringify({ message: `Not Found: ${req.method} ${url.pathname}` }));
|
|
49
50
|
return;
|
|
50
51
|
}
|
|
52
|
+
if (options.bearerAuth) {
|
|
53
|
+
try {
|
|
54
|
+
await (0, auth_1.authenticateBearerRequest)(req, options.bearerAuth);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
res.statusCode = 500;
|
|
58
|
+
res.setHeader("Content-Type", "application/json");
|
|
59
|
+
res.end(JSON.stringify({ message: "Internal server error" }));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
51
63
|
const query = {};
|
|
52
64
|
url.searchParams.forEach((value, key) => {
|
|
53
65
|
if (query[key]) {
|
|
@@ -78,7 +90,8 @@ async function createNativeApp(options) {
|
|
|
78
90
|
inputCoercion,
|
|
79
91
|
validation: options.validation,
|
|
80
92
|
body,
|
|
81
|
-
query
|
|
93
|
+
query,
|
|
94
|
+
auth: { userProperty: options.bearerAuth?.userProperty }
|
|
82
95
|
});
|
|
83
96
|
};
|
|
84
97
|
await lifecycle_1.lifecycleRegistry.callOnApplicationBootstrap();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import type { Constructor, RequestContext as CoreRequestContext, UploadedFileInfo } from "../../core/types";
|
|
3
3
|
import type { OpenApiInfo, OpenApiServer } from "../../core/openapi";
|
|
4
|
+
import type { BearerAuthOptions } from "../../core/auth";
|
|
4
5
|
export { UploadedFileInfo };
|
|
5
6
|
/**
|
|
6
7
|
* Request context provided to native route handlers.
|
|
@@ -63,6 +64,8 @@ export interface NativeAdapterOptions {
|
|
|
63
64
|
openApi?: OpenApiNativeOptions;
|
|
64
65
|
/** Input coercion setting */
|
|
65
66
|
inputCoercion?: InputCoercionSetting;
|
|
67
|
+
/** Built-in bearer token authentication for protected routes. */
|
|
68
|
+
bearerAuth?: BearerAuthOptions;
|
|
66
69
|
/** Validation configuration. Set to false to disable validation, or provide options. */
|
|
67
70
|
validation?: boolean | ValidationOptions;
|
|
68
71
|
}
|
package/dist/core/auth.d.ts
CHANGED
|
@@ -22,6 +22,10 @@ export interface AuthOptions {
|
|
|
22
22
|
* Function to extract user from request.
|
|
23
23
|
*/
|
|
24
24
|
export type AuthExtractor = (req: any) => AuthUser | null | Promise<AuthUser | null>;
|
|
25
|
+
/**
|
|
26
|
+
* Function to validate a bearer token and resolve the authenticated user.
|
|
27
|
+
*/
|
|
28
|
+
export type BearerTokenVerifier = (token: string, req: any) => AuthUser | null | Promise<AuthUser | null>;
|
|
25
29
|
/**
|
|
26
30
|
* Options for creating auth middleware.
|
|
27
31
|
*/
|
|
@@ -35,10 +39,19 @@ export interface AuthMiddlewareOptions {
|
|
|
35
39
|
/** Custom forbidden response */
|
|
36
40
|
onForbidden?: (req: any, res: any, reason?: string) => void;
|
|
37
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Options for built-in Bearer authentication.
|
|
44
|
+
*/
|
|
45
|
+
export interface BearerAuthOptions {
|
|
46
|
+
/** Function to validate token and return user context */
|
|
47
|
+
verifyToken: BearerTokenVerifier;
|
|
48
|
+
/** Property name to attach user to request (default: "user") */
|
|
49
|
+
userProperty?: string;
|
|
50
|
+
}
|
|
38
51
|
/**
|
|
39
52
|
* Metadata for authentication on routes/controllers.
|
|
40
53
|
*/
|
|
41
|
-
interface AuthMeta {
|
|
54
|
+
export interface AuthMeta {
|
|
42
55
|
/** Whether authentication is required */
|
|
43
56
|
requiresAuth: boolean;
|
|
44
57
|
/** Whether route is public (overrides controller-level auth) */
|
|
@@ -85,6 +98,10 @@ export declare function getRouteAuthMeta(controller: Constructor, handlerName: s
|
|
|
85
98
|
* Gets auth metadata for a controller.
|
|
86
99
|
*/
|
|
87
100
|
export declare function getControllerAuthMeta(controller: Constructor): AuthMeta | undefined;
|
|
101
|
+
/**
|
|
102
|
+
* Extracts a token from the Authorization: Bearer <token> header.
|
|
103
|
+
*/
|
|
104
|
+
export declare function extractBearerToken(req: any): string | undefined;
|
|
88
105
|
/**
|
|
89
106
|
* Creates Express middleware for authentication.
|
|
90
107
|
* Use this as a global middleware, then use route-level checks.
|
|
@@ -92,6 +109,20 @@ export declare function getControllerAuthMeta(controller: Constructor): AuthMeta
|
|
|
92
109
|
* @returns Express middleware function
|
|
93
110
|
*/
|
|
94
111
|
export declare function createAuthMiddleware(options: AuthMiddlewareOptions): (req: any, res: any, next: (err?: any) => void) => Promise<void>;
|
|
112
|
+
/**
|
|
113
|
+
* Verifies a bearer token from the request and attaches the user when valid.
|
|
114
|
+
*/
|
|
115
|
+
export declare function authenticateBearerRequest(req: any, options: BearerAuthOptions): Promise<AuthUser | null>;
|
|
116
|
+
/**
|
|
117
|
+
* Creates middleware that extracts and verifies Authorization bearer tokens.
|
|
118
|
+
*/
|
|
119
|
+
export declare function createBearerAuthMiddleware(options: BearerAuthOptions): (req: any, _res: any, next: (err?: any) => void) => Promise<void>;
|
|
120
|
+
/**
|
|
121
|
+
* Applies route auth metadata to a request that may already have a user attached.
|
|
122
|
+
*/
|
|
123
|
+
export declare function assertRouteAuthorized(authMeta: AuthMeta | undefined, req: any, options?: {
|
|
124
|
+
userProperty?: string;
|
|
125
|
+
}): Promise<void>;
|
|
95
126
|
/**
|
|
96
127
|
* Creates a route guard middleware that checks auth metadata.
|
|
97
128
|
* @param controller - Controller class
|
|
@@ -101,7 +132,7 @@ export declare function createAuthMiddleware(options: AuthMiddlewareOptions): (r
|
|
|
101
132
|
*/
|
|
102
133
|
export declare function createRouteGuard(controller: Constructor, handlerName: string | symbol, options?: {
|
|
103
134
|
userProperty?: string;
|
|
104
|
-
}): (req: any,
|
|
135
|
+
}): (req: any, _res: any, next: (err?: any) => void) => Promise<void>;
|
|
105
136
|
/**
|
|
106
137
|
* Helper to get user from request in controllers.
|
|
107
138
|
* @param req - Raw request
|
|
@@ -117,4 +148,3 @@ export declare function getUser<T extends AuthUser = AuthUser>(req: any, userPro
|
|
|
117
148
|
* @throws HttpError if user not found
|
|
118
149
|
*/
|
|
119
150
|
export declare function requireUser<T extends AuthUser = AuthUser>(req: any, userProperty?: string): T;
|
|
120
|
-
export {};
|
package/dist/core/auth.js
CHANGED
|
@@ -6,7 +6,11 @@ exports.AllRoles = AllRoles;
|
|
|
6
6
|
exports.Public = Public;
|
|
7
7
|
exports.getRouteAuthMeta = getRouteAuthMeta;
|
|
8
8
|
exports.getControllerAuthMeta = getControllerAuthMeta;
|
|
9
|
+
exports.extractBearerToken = extractBearerToken;
|
|
9
10
|
exports.createAuthMiddleware = createAuthMiddleware;
|
|
11
|
+
exports.authenticateBearerRequest = authenticateBearerRequest;
|
|
12
|
+
exports.createBearerAuthMiddleware = createBearerAuthMiddleware;
|
|
13
|
+
exports.assertRouteAuthorized = assertRouteAuthorized;
|
|
10
14
|
exports.createRouteGuard = createRouteGuard;
|
|
11
15
|
exports.getUser = getUser;
|
|
12
16
|
exports.requireUser = requireUser;
|
|
@@ -153,6 +157,19 @@ function hasAllRoles(user, roles) {
|
|
|
153
157
|
return false;
|
|
154
158
|
return roles.every((role) => user.roles.includes(role));
|
|
155
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* Extracts a token from the Authorization: Bearer <token> header.
|
|
162
|
+
*/
|
|
163
|
+
function extractBearerToken(req) {
|
|
164
|
+
const headers = req?.headers;
|
|
165
|
+
const rawHeader = headers?.authorization ?? headers?.Authorization;
|
|
166
|
+
const header = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader;
|
|
167
|
+
if (typeof header !== "string") {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
const match = header.match(/^Bearer\s+(\S+)$/i);
|
|
171
|
+
return match?.[1];
|
|
172
|
+
}
|
|
156
173
|
/**
|
|
157
174
|
* Creates Express middleware for authentication.
|
|
158
175
|
* Use this as a global middleware, then use route-level checks.
|
|
@@ -174,6 +191,61 @@ function createAuthMiddleware(options) {
|
|
|
174
191
|
}
|
|
175
192
|
};
|
|
176
193
|
}
|
|
194
|
+
/**
|
|
195
|
+
* Verifies a bearer token from the request and attaches the user when valid.
|
|
196
|
+
*/
|
|
197
|
+
async function authenticateBearerRequest(req, options) {
|
|
198
|
+
const token = extractBearerToken(req);
|
|
199
|
+
if (!token) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
const user = await options.verifyToken(token, req);
|
|
203
|
+
if (user) {
|
|
204
|
+
req[options.userProperty ?? "user"] = user;
|
|
205
|
+
}
|
|
206
|
+
return user;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Creates middleware that extracts and verifies Authorization bearer tokens.
|
|
210
|
+
*/
|
|
211
|
+
function createBearerAuthMiddleware(options) {
|
|
212
|
+
return async (req, _res, next) => {
|
|
213
|
+
try {
|
|
214
|
+
await authenticateBearerRequest(req, options);
|
|
215
|
+
next();
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
next(error);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Applies route auth metadata to a request that may already have a user attached.
|
|
224
|
+
*/
|
|
225
|
+
async function assertRouteAuthorized(authMeta, req, options = {}) {
|
|
226
|
+
if (!authMeta || authMeta.isPublic || !authMeta.requiresAuth) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const userProperty = options.userProperty ?? "user";
|
|
230
|
+
const requestRecord = req;
|
|
231
|
+
const rawRecord = (req?.raw ?? {});
|
|
232
|
+
const user = (requestRecord[userProperty] ?? rawRecord[userProperty]);
|
|
233
|
+
if (!user) {
|
|
234
|
+
throw new errors_1.HttpError(401, "Unauthorized");
|
|
235
|
+
}
|
|
236
|
+
if (authMeta.roles?.length && !hasAnyRole(user, authMeta.roles)) {
|
|
237
|
+
throw new errors_1.HttpError(403, "Insufficient permissions");
|
|
238
|
+
}
|
|
239
|
+
if (authMeta.allRoles?.length && !hasAllRoles(user, authMeta.allRoles)) {
|
|
240
|
+
throw new errors_1.HttpError(403, "Insufficient permissions");
|
|
241
|
+
}
|
|
242
|
+
if (authMeta.guard) {
|
|
243
|
+
const allowed = await authMeta.guard(user, req);
|
|
244
|
+
if (!allowed) {
|
|
245
|
+
throw new errors_1.HttpError(403, "Access denied by guard");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
177
249
|
/**
|
|
178
250
|
* Creates a route guard middleware that checks auth metadata.
|
|
179
251
|
* @param controller - Controller class
|
|
@@ -182,29 +254,9 @@ function createAuthMiddleware(options) {
|
|
|
182
254
|
* @returns Express middleware function
|
|
183
255
|
*/
|
|
184
256
|
function createRouteGuard(controller, handlerName, options = {}) {
|
|
185
|
-
const userProperty = options.userProperty ?? "user";
|
|
186
257
|
const authMeta = getRouteAuthMeta(controller, handlerName);
|
|
187
|
-
return async (req,
|
|
188
|
-
|
|
189
|
-
next();
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
const user = req[userProperty];
|
|
193
|
-
if (!user) {
|
|
194
|
-
throw new errors_1.HttpError(401, "Unauthorized");
|
|
195
|
-
}
|
|
196
|
-
if (authMeta.roles?.length && !hasAnyRole(user, authMeta.roles)) {
|
|
197
|
-
throw new errors_1.HttpError(403, "Insufficient permissions");
|
|
198
|
-
}
|
|
199
|
-
if (authMeta.allRoles?.length && !hasAllRoles(user, authMeta.allRoles)) {
|
|
200
|
-
throw new errors_1.HttpError(403, "Insufficient permissions");
|
|
201
|
-
}
|
|
202
|
-
if (authMeta.guard) {
|
|
203
|
-
const allowed = await authMeta.guard(user, req);
|
|
204
|
-
if (!allowed) {
|
|
205
|
-
throw new errors_1.HttpError(403, "Access denied by guard");
|
|
206
|
-
}
|
|
207
|
-
}
|
|
258
|
+
return async (req, _res, next) => {
|
|
259
|
+
await assertRouteAuthorized(authMeta, req, options);
|
|
208
260
|
next();
|
|
209
261
|
};
|
|
210
262
|
}
|
package/dist/core/openapi.d.ts
CHANGED
package/dist/core/openapi.js
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.buildOpenApi = buildOpenApi;
|
|
4
4
|
const schema_builder_1 = require("./schema-builder");
|
|
5
5
|
const metadata_1 = require("./metadata");
|
|
6
|
+
const auth_1 = require("./auth");
|
|
6
7
|
/**
|
|
7
8
|
* Validates that all registered DTOs have non-empty field metadata.
|
|
8
9
|
* Throws an error with helpful diagnostic information if empty schemas are found.
|
|
@@ -39,6 +40,7 @@ function buildOpenApi(options) {
|
|
|
39
40
|
const context = (0, schema_builder_1.createSchemaContext)();
|
|
40
41
|
const controllers = filterControllers(options.controllers);
|
|
41
42
|
const paths = {};
|
|
43
|
+
let hasBearerAuth = false;
|
|
42
44
|
for (const controller of controllers) {
|
|
43
45
|
const tagFallback = controller.meta.tags ?? [controller.meta.controller.name];
|
|
44
46
|
for (const route of controller.meta.routes) {
|
|
@@ -56,6 +58,13 @@ function buildOpenApi(options) {
|
|
|
56
58
|
const requestBody = hasFiles
|
|
57
59
|
? buildMultipartRequestBody(route.files, route.body, context)
|
|
58
60
|
: buildRequestBody(route.body, context);
|
|
61
|
+
const authMeta = (0, auth_1.getRouteAuthMeta)(controller.meta.controller, route.handlerName);
|
|
62
|
+
const security = authMeta?.requiresAuth && !authMeta.isPublic
|
|
63
|
+
? [{ bearerAuth: [] }]
|
|
64
|
+
: undefined;
|
|
65
|
+
if (security) {
|
|
66
|
+
hasBearerAuth = true;
|
|
67
|
+
}
|
|
59
68
|
pathItem[route.httpMethod] = {
|
|
60
69
|
operationId: `${controller.meta.controller.name}.${String(route.handlerName)}`,
|
|
61
70
|
summary: route.summary,
|
|
@@ -63,6 +72,7 @@ function buildOpenApi(options) {
|
|
|
63
72
|
tags: route.tags ?? tagFallback,
|
|
64
73
|
parameters: parameters.length ? parameters : undefined,
|
|
65
74
|
requestBody,
|
|
75
|
+
security,
|
|
66
76
|
responses
|
|
67
77
|
};
|
|
68
78
|
}
|
|
@@ -74,7 +84,15 @@ function buildOpenApi(options) {
|
|
|
74
84
|
servers: options.servers,
|
|
75
85
|
paths,
|
|
76
86
|
components: {
|
|
77
|
-
schemas: context.components
|
|
87
|
+
schemas: context.components,
|
|
88
|
+
securitySchemes: hasBearerAuth
|
|
89
|
+
? {
|
|
90
|
+
bearerAuth: {
|
|
91
|
+
type: "http",
|
|
92
|
+
scheme: "bearer"
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
: undefined
|
|
78
96
|
}
|
|
79
97
|
};
|
|
80
98
|
validateSchemas(context.components);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createExpressApp, type AuthUser } from "../../src";
|
|
2
|
+
import { AuthDemoController } from "./auth.controller";
|
|
3
|
+
|
|
4
|
+
function verifyToken(token: string): AuthUser | null {
|
|
5
|
+
if (token === "user-token") {
|
|
6
|
+
return { id: "user-1", roles: ["user"] };
|
|
7
|
+
}
|
|
8
|
+
if (token === "admin-token") {
|
|
9
|
+
return { id: "admin-1", roles: ["user", "admin"] };
|
|
10
|
+
}
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function createApp() {
|
|
15
|
+
return createExpressApp({
|
|
16
|
+
controllers: [AuthDemoController],
|
|
17
|
+
bearerAuth: { verifyToken },
|
|
18
|
+
openApi: {
|
|
19
|
+
info: {
|
|
20
|
+
title: "Bearer Auth Swagger Demo",
|
|
21
|
+
version: "1.0.0",
|
|
22
|
+
description: "Demo for Authorization: Bearer <token> with protected OpenAPI routes."
|
|
23
|
+
},
|
|
24
|
+
path: "/openapi.json",
|
|
25
|
+
docs: true
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Auth,
|
|
3
|
+
Controller,
|
|
4
|
+
Get,
|
|
5
|
+
Public,
|
|
6
|
+
Returns,
|
|
7
|
+
Roles,
|
|
8
|
+
getUser,
|
|
9
|
+
type AuthUser
|
|
10
|
+
} from "../../src";
|
|
11
|
+
import { PublicStatusDto, SessionDto } from "./session.dtos";
|
|
12
|
+
|
|
13
|
+
@Controller("/auth-demo")
|
|
14
|
+
export class AuthDemoController {
|
|
15
|
+
@Get("/public")
|
|
16
|
+
@Public()
|
|
17
|
+
@Returns(PublicStatusDto)
|
|
18
|
+
publicStatus() {
|
|
19
|
+
return { status: "public route, no bearer token required" };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@Get("/me")
|
|
23
|
+
@Auth()
|
|
24
|
+
@Returns(SessionDto)
|
|
25
|
+
me(ctx: any) {
|
|
26
|
+
const user = getUser<AuthUser>(ctx.req)!;
|
|
27
|
+
return {
|
|
28
|
+
id: user.id,
|
|
29
|
+
roles: user.roles ?? [],
|
|
30
|
+
message: "Bearer token accepted"
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@Get("/admin")
|
|
35
|
+
@Roles("admin")
|
|
36
|
+
@Returns(SessionDto)
|
|
37
|
+
admin(ctx: any) {
|
|
38
|
+
const user = getUser<AuthUser>(ctx.req)!;
|
|
39
|
+
return {
|
|
40
|
+
id: user.id,
|
|
41
|
+
roles: user.roles ?? [],
|
|
42
|
+
message: "Admin role accepted"
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|