@xtaskjs/security 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # @xtaskjs/security
2
+
3
+ Security integration package for xtaskjs.
4
+
5
+ ## Installation
6
+ ```bash
7
+ npm install @xtaskjs/security passport passport-jwt reflect-metadata
8
+ ```
9
+
10
+ ## What It Provides
11
+ - Passport-backed JWT and JWE authentication.
12
+ - Authorization decorators for controller routes.
13
+ - DI tokens for auth services, Passport, and the lifecycle manager.
14
+ - Lifecycle initialization integrated with `CreateApplication()` and `app.close()`.
15
+
16
+ ## Register Strategies
17
+ ```typescript
18
+ import { registerJwtStrategy, registerJweStrategy } from "@xtaskjs/security";
19
+
20
+ registerJwtStrategy({
21
+ name: "default",
22
+ default: true,
23
+ secretOrKey: process.env.JWT_SECRET,
24
+ });
25
+
26
+ registerJweStrategy({
27
+ name: "encrypted",
28
+ decryptionKey: process.env.JWE_SECRET,
29
+ });
30
+ ```
31
+
32
+ ## Validate Callbacks
33
+ Use `validate` to enforce application-specific claims and load the current user from your DI container.
34
+
35
+ ```typescript
36
+ import { registerJwtStrategy } from "@xtaskjs/security";
37
+ import { CreateApplication } from "@xtaskjs/core";
38
+
39
+ class UserDirectory {
40
+ async findActiveUser(id: string) {
41
+ return id === "alice" ? { id: "alice", roles: ["admin"] } : undefined;
42
+ }
43
+ }
44
+
45
+ registerJwtStrategy({
46
+ name: "default",
47
+ default: true,
48
+ secretOrKey: process.env.JWT_SECRET,
49
+ validate: async (payload, context) => {
50
+ if (payload.tenant !== "xtaskjs") {
51
+ return false;
52
+ }
53
+
54
+ const userDirectory = context.container?.get(UserDirectory);
55
+ const user = await userDirectory?.findActiveUser(String(payload.sub || ""));
56
+ if (!user) {
57
+ return false;
58
+ }
59
+
60
+ return {
61
+ sub: user.id,
62
+ roles: user.roles,
63
+ claims: payload,
64
+ };
65
+ },
66
+ });
67
+
68
+ await CreateApplication();
69
+ ```
70
+
71
+ ## Secure Controllers
72
+ ```typescript
73
+ import { Controller, Get } from "@xtaskjs/common";
74
+ import { Authenticated, Roles, AllowAnonymous } from "@xtaskjs/security";
75
+
76
+ @Controller("admin")
77
+ @Authenticated()
78
+ class AdminController {
79
+ @Get("/profile")
80
+ @Roles("admin")
81
+ profile(req: any) {
82
+ return {
83
+ user: req.user,
84
+ auth: req.auth,
85
+ };
86
+ }
87
+
88
+ @Get("/health")
89
+ @AllowAnonymous()
90
+ health() {
91
+ return { ok: true };
92
+ }
93
+ }
94
+ ```
95
+
96
+ ## DI Integration
97
+ ```typescript
98
+ import { Service } from "@xtaskjs/core";
99
+ import {
100
+ InjectAuthenticationService,
101
+ InjectAuthorizationService,
102
+ SecurityAuthenticationService,
103
+ SecurityAuthorizationService,
104
+ } from "@xtaskjs/security";
105
+
106
+ @Service()
107
+ class SecurityAwareService {
108
+ constructor(
109
+ @InjectAuthenticationService()
110
+ private readonly authentication: SecurityAuthenticationService,
111
+ @InjectAuthorizationService()
112
+ private readonly authorization: SecurityAuthorizationService
113
+ ) {}
114
+ }
115
+ ```
@@ -0,0 +1,3 @@
1
+ module.exports = {
2
+ presets: ["next/babel"],
3
+ };
package/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./src";
package/jest.config.js ADDED
@@ -0,0 +1,18 @@
1
+ /** @type {import("jest").Config} **/
2
+ module.exports = {
3
+ testEnvironment: "node",
4
+ transform: {
5
+ "^.+\\.tsx?$": [
6
+ "ts-jest",
7
+ {
8
+ tsconfig: "<rootDir>/tsconfig.test.json",
9
+ },
10
+ ],
11
+ },
12
+ moduleNameMapper: {
13
+ "^@xtaskjs/common$": "<rootDir>/../common/src/index.ts",
14
+ "^@xtaskjs/common/(.*)$": "<rootDir>/../common/src/$1",
15
+ "^@xtaskjs/core$": "<rootDir>/../core/src/index.ts",
16
+ "^@xtaskjs/core/(.*)$": "<rootDir>/../core/src/$1"
17
+ },
18
+ };
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@xtaskjs/security",
3
+ "version": "1.0.0",
4
+ "description": "xtaskjs security integration package",
5
+ "author": "Javier Rodriguez Soler",
6
+ "homepage": "https://xtaskjs.com",
7
+ "main": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "require": "./dist/index.js",
13
+ "default": "./dist/index.js"
14
+ }
15
+ },
16
+ "scripts": {
17
+ "build": "rm -rf dist && tsc",
18
+ "test": "jest --passWithNoTests",
19
+ "test:coverage": "jest --coverage --runInBand",
20
+ "coverage:open": "xdg-open coverage/lcov-report/index.html"
21
+ },
22
+ "funding": {
23
+ "type": "opencollective",
24
+ "url": "https://opencollective.com/xtaskjs"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/xtaskjs/xtaskjs.git",
29
+ "directory": "packages/security"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "license": "MIT",
35
+ "dependencies": {
36
+ "@xtaskjs/common": "^1.0.13",
37
+ "@xtaskjs/core": "^1.0.13",
38
+ "passport": "^0.7.0",
39
+ "passport-jwt": "^4.0.1",
40
+ "passport-strategy": "^1.0.0"
41
+ },
42
+ "peerDependencies": {
43
+ "reflect-metadata": "^0.1.12 || ^0.2.0"
44
+ },
45
+ "devDependencies": {
46
+ "@jest/globals": "^30.1.2",
47
+ "@types/jest": "^30.0.0",
48
+ "jest": "^30.1.3",
49
+ "nodemon": "^3.1.10",
50
+ "ts-jest": "^29.4.4",
51
+ "ts-node": "^10.9.2",
52
+ "tsconfig-paths": "^4.2.0",
53
+ "typescript": "^5.9.2"
54
+ }
55
+ }
@@ -0,0 +1,27 @@
1
+ import { RouteExecutionContext } from "@xtaskjs/common";
2
+ import { UnauthorizedError } from "@xtaskjs/core";
3
+ import { getSecurityLifecycleManager } from "./lifecycle";
4
+ import { SecurityAuthenticationResult } from "./types";
5
+
6
+ export class SecurityAuthenticationService {
7
+ async authenticate(
8
+ context: RouteExecutionContext,
9
+ strategyNames?: string | string[]
10
+ ): Promise<SecurityAuthenticationResult> {
11
+ const result = await getSecurityLifecycleManager().authenticateContext(context, strategyNames);
12
+ if (!result.success) {
13
+ throw new UnauthorizedError(result.challenge?.message || "Unauthorized", {
14
+ message: result.challenge?.message || "Unauthorized",
15
+ });
16
+ }
17
+ return result;
18
+ }
19
+
20
+ async authenticateRequest(
21
+ request: any,
22
+ response?: any,
23
+ strategyNames?: string | string[]
24
+ ): Promise<SecurityAuthenticationResult> {
25
+ return getSecurityLifecycleManager().authenticateRequest(request, response, strategyNames);
26
+ }
27
+ }
@@ -0,0 +1,66 @@
1
+ import { RouteAuthenticationContext, RouteExecutionContext } from "@xtaskjs/common";
2
+ import { SecurityRoleMatchingMode } from "./types";
3
+
4
+ const normalizeRoles = (input: unknown): string[] => {
5
+ if (Array.isArray(input)) {
6
+ return input
7
+ .map((value) => String(value || "").trim())
8
+ .filter((value) => value.length > 0);
9
+ }
10
+
11
+ if (typeof input === "string") {
12
+ return input
13
+ .split(/[\s,]+/)
14
+ .map((value) => value.trim())
15
+ .filter((value) => value.length > 0);
16
+ }
17
+
18
+ return [];
19
+ };
20
+
21
+ export class SecurityAuthorizationService {
22
+ getRoles(source: RouteExecutionContext | RouteAuthenticationContext | any): string[] {
23
+ if (!source) {
24
+ return [];
25
+ }
26
+
27
+ if (Array.isArray(source.roles)) {
28
+ return normalizeRoles(source.roles);
29
+ }
30
+
31
+ if (source.auth && Array.isArray(source.auth.roles)) {
32
+ return normalizeRoles(source.auth.roles);
33
+ }
34
+
35
+ if (Array.isArray(source.user?.roles) || typeof source.user?.roles === "string") {
36
+ return normalizeRoles(source.user.roles);
37
+ }
38
+
39
+ if (Array.isArray(source.claims?.roles) || typeof source.claims?.roles === "string") {
40
+ return normalizeRoles(source.claims.roles);
41
+ }
42
+
43
+ if (typeof source.claims?.scope === "string") {
44
+ return normalizeRoles(source.claims.scope);
45
+ }
46
+
47
+ return [];
48
+ }
49
+
50
+ isAuthorized(
51
+ source: RouteExecutionContext | RouteAuthenticationContext | any,
52
+ requiredRoles: string[],
53
+ mode: SecurityRoleMatchingMode = "any"
54
+ ): boolean {
55
+ if (requiredRoles.length === 0) {
56
+ return true;
57
+ }
58
+
59
+ const grantedRoles = new Set(this.getRoles(source));
60
+ if (mode === "all") {
61
+ return requiredRoles.every((role) => grantedRoles.has(role));
62
+ }
63
+
64
+ return requiredRoles.some((role) => grantedRoles.has(role));
65
+ }
66
+ }
@@ -0,0 +1,90 @@
1
+ import {
2
+ JweSecurityStrategyOptions,
3
+ JwtSecurityStrategyOptions,
4
+ RegisteredJweSecurityStrategyOptions,
5
+ RegisteredJwtSecurityStrategyOptions,
6
+ RegisteredSecurityStrategyOptions,
7
+ } from "./types";
8
+
9
+ const DEFAULT_STRATEGY_NAME = "default";
10
+ const registeredStrategies = new Map<string, RegisteredSecurityStrategyOptions>();
11
+
12
+ const resolveStrategyName = (
13
+ kind: RegisteredSecurityStrategyOptions["kind"],
14
+ requestedName?: string
15
+ ): string => {
16
+ if (typeof requestedName === "string" && requestedName.trim().length > 0) {
17
+ return requestedName.trim();
18
+ }
19
+
20
+ if (!registeredStrategies.has(DEFAULT_STRATEGY_NAME)) {
21
+ return DEFAULT_STRATEGY_NAME;
22
+ }
23
+
24
+ return `${kind}-${registeredStrategies.size + 1}`;
25
+ };
26
+
27
+ export const registerJwtStrategy = (
28
+ options: JwtSecurityStrategyOptions
29
+ ): RegisteredJwtSecurityStrategyOptions => {
30
+ const name = resolveStrategyName("jwt", options.name);
31
+ const definition: RegisteredJwtSecurityStrategyOptions = {
32
+ ...options,
33
+ kind: "jwt",
34
+ name,
35
+ };
36
+ registeredStrategies.set(name, definition);
37
+ return definition;
38
+ };
39
+
40
+ export const registerJweStrategy = (
41
+ options: JweSecurityStrategyOptions
42
+ ): RegisteredJweSecurityStrategyOptions => {
43
+ const name = resolveStrategyName("jwe", options.name);
44
+ const definition: RegisteredJweSecurityStrategyOptions = {
45
+ ...options,
46
+ kind: "jwe",
47
+ name,
48
+ };
49
+ registeredStrategies.set(name, definition);
50
+ return definition;
51
+ };
52
+
53
+ export const getRegisteredSecurityStrategies = (): RegisteredSecurityStrategyOptions[] => {
54
+ return Array.from(registeredStrategies.values());
55
+ };
56
+
57
+ export const clearRegisteredSecurityStrategies = (): void => {
58
+ registeredStrategies.clear();
59
+ };
60
+
61
+ export const getDefaultSecurityStrategyName = (): string | undefined => {
62
+ const explicitDefault = Array.from(registeredStrategies.values()).find(
63
+ (definition) => definition.default === true
64
+ );
65
+ if (explicitDefault) {
66
+ return explicitDefault.name;
67
+ }
68
+
69
+ if (registeredStrategies.size === 1) {
70
+ return Array.from(registeredStrategies.keys())[0];
71
+ }
72
+
73
+ if (registeredStrategies.has(DEFAULT_STRATEGY_NAME)) {
74
+ return DEFAULT_STRATEGY_NAME;
75
+ }
76
+
77
+ return undefined;
78
+ };
79
+
80
+ export const JwtSecurityStrategy = (options: JwtSecurityStrategyOptions): ClassDecorator => {
81
+ return () => {
82
+ registerJwtStrategy(options);
83
+ };
84
+ };
85
+
86
+ export const JweSecurityStrategy = (options: JweSecurityStrategyOptions): ClassDecorator => {
87
+ return () => {
88
+ registerJweStrategy(options);
89
+ };
90
+ };
@@ -0,0 +1,79 @@
1
+ import { UseGuards } from "@xtaskjs/common";
2
+ import { authenticationGuard, authorizationGuard } from "./guards";
3
+ import {
4
+ normalizeStrategies,
5
+ setAllowAnonymousMetadata,
6
+ setAuthenticatedMetadata,
7
+ setRolesMetadata,
8
+ } from "./metadata";
9
+ import { AuthenticatedOptions, RolesOptions } from "./types";
10
+
11
+ const applyDecorator = (
12
+ decorator: MethodDecorator & ClassDecorator,
13
+ target: any,
14
+ propertyKey?: string | symbol,
15
+ descriptor?: PropertyDescriptor
16
+ ): void => {
17
+ if (propertyKey === undefined) {
18
+ decorator(target);
19
+ return;
20
+ }
21
+
22
+ decorator(target, propertyKey, descriptor!);
23
+ };
24
+
25
+ const normalizeAuthenticatedOptions = (
26
+ value?: string | string[] | AuthenticatedOptions
27
+ ): AuthenticatedOptions => {
28
+ if (!value) {
29
+ return {};
30
+ }
31
+
32
+ if (typeof value === "string" || Array.isArray(value)) {
33
+ return { strategies: value };
34
+ }
35
+
36
+ return value;
37
+ };
38
+
39
+ export const Authenticated = (
40
+ value?: string | string[] | AuthenticatedOptions
41
+ ): MethodDecorator & ClassDecorator => {
42
+ const options = normalizeAuthenticatedOptions(value);
43
+ const strategies = normalizeStrategies(options.strategies);
44
+ const guardDecorator = UseGuards(authenticationGuard);
45
+
46
+ return (target: any, propertyKey?: string | symbol, descriptor?: PropertyDescriptor) => {
47
+ setAuthenticatedMetadata(target, propertyKey, strategies);
48
+ applyDecorator(guardDecorator, target, propertyKey, descriptor);
49
+ };
50
+ };
51
+
52
+ export const Auth = Authenticated;
53
+
54
+ const normalizeRolesOptions = (value: RolesOptions | string, roles: string[]): RolesOptions => {
55
+ if (typeof value === "string") {
56
+ return { roles: [value, ...roles] };
57
+ }
58
+
59
+ return value;
60
+ };
61
+
62
+ export const Roles = (
63
+ value: RolesOptions | string,
64
+ ...roles: string[]
65
+ ): MethodDecorator & ClassDecorator => {
66
+ const options = normalizeRolesOptions(value, roles);
67
+ const guardDecorator = UseGuards(authorizationGuard);
68
+
69
+ return (target: any, propertyKey?: string | symbol, descriptor?: PropertyDescriptor) => {
70
+ setRolesMetadata(target, propertyKey, options);
71
+ applyDecorator(guardDecorator, target, propertyKey, descriptor);
72
+ };
73
+ };
74
+
75
+ export const AllowAnonymous = (): MethodDecorator & ClassDecorator => {
76
+ return (target: any, propertyKey?: string | symbol) => {
77
+ setAllowAnonymousMetadata(target, propertyKey);
78
+ };
79
+ };
package/src/guards.ts ADDED
@@ -0,0 +1,58 @@
1
+ import { GuardLike, RouteExecutionContext } from "@xtaskjs/common";
2
+ import { ForbiddenError } from "@xtaskjs/core";
3
+ import { SecurityAuthenticationService } from "./authentication";
4
+ import { SecurityAuthorizationService } from "./authorization";
5
+ import { resolveSecurityMetadata } from "./metadata";
6
+
7
+ const AUTHENTICATION_GUARD_STATE_KEY = "xtask:security:authentication-checked";
8
+ const AUTHORIZATION_GUARD_STATE_KEY = "xtask:security:authorization-checked";
9
+
10
+ const authenticationService = new SecurityAuthenticationService();
11
+ const authorizationService = new SecurityAuthorizationService();
12
+
13
+ export const authenticationGuard: GuardLike = {
14
+ async canActivate(context: RouteExecutionContext): Promise<boolean> {
15
+ if (context.state[AUTHENTICATION_GUARD_STATE_KEY]) {
16
+ return true;
17
+ }
18
+
19
+ const metadata = resolveSecurityMetadata(context.controller?.constructor, context.handler);
20
+ if (metadata.allowAnonymous) {
21
+ context.state[AUTHENTICATION_GUARD_STATE_KEY] = true;
22
+ return true;
23
+ }
24
+
25
+ await authenticationService.authenticate(context, metadata.strategies);
26
+ context.state[AUTHENTICATION_GUARD_STATE_KEY] = true;
27
+ return true;
28
+ },
29
+ };
30
+
31
+ export const authorizationGuard: GuardLike = {
32
+ async canActivate(context: RouteExecutionContext): Promise<boolean> {
33
+ if (context.state[AUTHORIZATION_GUARD_STATE_KEY]) {
34
+ return true;
35
+ }
36
+
37
+ const metadata = resolveSecurityMetadata(context.controller?.constructor, context.handler);
38
+ if (metadata.allowAnonymous || metadata.roles.length === 0) {
39
+ context.state[AUTHORIZATION_GUARD_STATE_KEY] = true;
40
+ return true;
41
+ }
42
+
43
+ if (!context.auth.isAuthenticated) {
44
+ await authenticationGuard.canActivate(context);
45
+ }
46
+
47
+ if (!authorizationService.isAuthorized(context.auth, metadata.roles, metadata.roleMode)) {
48
+ throw new ForbiddenError("Forbidden", {
49
+ message: "Forbidden",
50
+ requiredRoles: metadata.roles,
51
+ actualRoles: context.auth.roles,
52
+ });
53
+ }
54
+
55
+ context.state[AUTHORIZATION_GUARD_STATE_KEY] = true;
56
+ return true;
57
+ },
58
+ };
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ import "reflect-metadata";
2
+
3
+ export * from "./types";
4
+ export * from "./tokens";
5
+ export * from "./configuration";
6
+ export * from "./authentication";
7
+ export * from "./authorization";
8
+ export * from "./decorators";
9
+ export * from "./guards";
10
+ export * from "./lifecycle";