Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/Http/Scope/types.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { Context, DefaultState, Next, Request } from 'koa';
import { type HttpRequest } from '../Request';
import { type HttpResponse } from '../Response';
import { type User } from '@/Security/types';

export interface HttpScope<TRequest extends Request = HttpRequest> extends Context {
export interface HttpScope<TRequest extends Request = HttpRequest, TUser extends User = User> extends Context {
request: TRequest;
response: HttpResponse;
user?: TUser;
}

export type NextMiddleware = Next;
Expand Down
95 changes: 95 additions & 0 deletions src/Security/firewall/firewall-middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, expect, test, vi } from 'vitest';
import { type HttpScope } from '@/Http';
import { firewall } from '@/Security/firewall/firewall-middleware';

describe('Firewall Middleware', () => {
test('it should skip unmatched firewalls', async () => {
const userProvider = vi.fn();
const config = {
firewalls: [
{
pattern: '^/admin',
security: true,
provider: userProvider,
},
],
};
const middleware = firewall(config);
const next = vi.fn();
const request = { path: '/public' };

await middleware({ request } as HttpScope, next);

expect(userProvider).not.toHaveBeenCalled();
expect(next).toHaveBeenCalled();
});

test('it should skip unsecured firewall', async () => {
const userProvider = vi.fn();
const config = {
firewalls: [
{
pattern: '^/public',
security: false,
provider: userProvider,
},
],
};
const middleware = firewall(config);
const next = vi.fn();
const request = { path: '/public' };

await middleware({ request } as HttpScope, next);

expect(userProvider).not.toHaveBeenCalled();
expect(next).toHaveBeenCalled();
});

test('it should pass if user is already authenticated', async () => {
const userProvider = vi.fn();
const config = {
firewalls: [
{
pattern: '^/admin',
security: true,
provider: userProvider,
},
],
};
const middleware = firewall(config);
const next = vi.fn();
const request = { path: '/admin' };
const scope = { request, user: { identifier: 'user-1' } } as unknown as HttpScope;

await middleware(scope, next);

expect(userProvider).not.toHaveBeenCalled();
expect(next).toHaveBeenCalled();
});

test('it should stop execution if authentication fails', async () => {
const userProvider = vi.fn().mockResolvedValue(undefined);
const config = {
firewalls: [
{
pattern: '^/admin',
security: true,
provider: userProvider,
},
],
};
const middleware = firewall(config);
const next = vi.fn();
const request = { path: '/admin' };
const internalThrow = vi.fn();
const scope = {
request,
throw: internalThrow,
} as unknown as HttpScope;

await middleware(scope, next);

expect(userProvider).toHaveBeenCalled();
expect(internalThrow).toHaveBeenCalledWith(401, 'Authentication required');
});
});
28 changes: 28 additions & 0 deletions src/Security/firewall/firewall-middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { type HttpMiddleware, type HttpScope, type NextMiddleware } from '@/Http';
import { type Firewall, type SecurityConfig } from '@/Security/types';

export function firewall(config: SecurityConfig): HttpMiddleware {
return async function firewallMiddleware(scope: HttpScope, next: NextMiddleware): Promise<void> {
const firewall = config.firewalls.find(fw => {
const regex = new RegExp(fw.pattern);
return regex.test(scope.request.path);
});

if (undefined !== firewall) {
await runFirewall(firewall, scope);
}

await next();
};
}

async function runFirewall(firewall: Firewall, scope: HttpScope): Promise<void> {
if (!firewall.security) return;
if (undefined !== scope.user) return;

const user = await firewall.provider?.(scope.request);

if (undefined === user) scope.throw(401, 'Authentication required');

scope.user = user;
}
2 changes: 2 additions & 0 deletions src/Security/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './types';
export * from './firewall/firewall-middleware';
17 changes: 17 additions & 0 deletions src/Security/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { type HttpRequest } from '@/Http';

export interface User {
roles?: string[];
}

export type UserProvider = (request: HttpRequest) => Promise<User | undefined>;

export interface Firewall {
pattern: string;
security: boolean;
provider?: UserProvider;
}

export interface SecurityConfig {
firewalls: Firewall[];
}
22 changes: 20 additions & 2 deletions src/Testing/TestAgentFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,25 @@ import supertest from 'supertest';
import { type TestAgent } from './types';
import { create } from '@/Application';
import { type KoalaConfig } from '@/Config';
import { type HttpMiddleware, type HttpScope, type NextMiddleware } from '@/Http';
import { type User } from '@/Security/types';

export function createTestAgent(config: KoalaConfig): TestAgent {
return supertest(create(config).callback());
export function createTestAgent(config: KoalaConfig, agentConfig?: { actAs?: User }): TestAgent {
const globalMiddleware = config.globalMiddleware ?? [];
const testConfig = { ...config };

const { actAs } = agentConfig ?? {};

if (undefined !== actAs) {
testConfig.globalMiddleware = [actAsUser(actAs), ...globalMiddleware];
}

return supertest(create(testConfig).callback());
}

function actAsUser(user: User): HttpMiddleware {
return async function actAsUserMiddleware(scope: HttpScope, next: NextMiddleware): Promise<void> {
scope.user = user;
await next();
};
}
101 changes: 101 additions & 0 deletions tests/security-authentication.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect, test, vi } from 'vitest';
import { createTestAgent, type HttpScope, Route } from '../src';
import { firewall } from '../src/Security/firewall/firewall-middleware';

class MyController {
@Route({ path: '/public', method: 'get' })
publicResource(scope: HttpScope): void {
scope.response.body = 'Hello, World!';
}

@Route({ path: '/api/v1/protected', method: 'get' })
protectedResource(scope: HttpScope): void {
scope.response.body = 'Protected Resource';
}
}

const userProvider = vi.fn();
const securityConfig = {
firewalls: [
{
pattern: '^/public',
security: false,
},
{
pattern: '^/api',
security: true,
},
{
pattern: '^/protected/with/provider',
security: true,
provider: userProvider,
},
],
};

describe('Security Authentication E2E Tests', () => {
test('Firewall should block access to protected route', async () => {
const config = {
controllers: [MyController],
globalMiddleware: [firewall(securityConfig)],
};
const agent = createTestAgent(config);

const response = await agent.get('/api/v1/protected');

expect(response.status).toBe(401);
expect(response.text).toBe('Authentication required');
});

test('Firewall should allow only authenticated users to access protected route', async () => {
const config = {
controllers: [MyController],
globalMiddleware: [firewall(securityConfig)],
};
const MyUser = { identifier: 'user-1' };
const agent = createTestAgent(config, { actAs: MyUser });

const response = await agent.get('/api/v1/protected');

expect(response.status).toBe(200);
expect(response.text).toBe('Protected Resource');
});

test('Firewall should allow access to unprotected route', async () => {
const config = {
controllers: [MyController],
globalMiddleware: [firewall(securityConfig)],
};
const agent = createTestAgent(config);

const response = await agent.get('/public');

expect(response.status).toBe(200);
expect(response.text).toBe('Hello, World!');
});

test('Firewall should depend on specified user provider for authentication', async () => {
userProvider.mockResolvedValue({ identifier: 'provided-user' });
const config = {
controllers: [MyController],
globalMiddleware: [firewall(securityConfig)],
};
const agent = createTestAgent(config);

await agent.get('/protected/with/provider');

expect(userProvider).toHaveBeenCalled();
});

test('Firewall should bypass routes without firewall', async () => {
const config = {
controllers: [MyController],
globalMiddleware: [firewall(securityConfig)],
};
const agent = createTestAgent(config);

const response = await agent.get('/no/firewall/here');

expect(response.status).toBe(404);
});
});