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
6 changes: 1 addition & 5 deletions backend/services/notification/alerting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,8 @@
* Channels are pluggable; add as many as needed.
*/

<<<<<<< HEAD:backend/services/alerting.ts
import { logger } from './logging';
import type { Alert, AlertChannelConfig } from './types';
=======
import { logger } from '../shared/logging';
import type { Alert, AlertChannelConfig } from '../shared/types';
>>>>>>> main:backend/services/notification/alerting.ts

export interface AlertDispatcher {
send(alert: Alert): Promise<void>;
Expand Down
105 changes: 105 additions & 0 deletions backend/services/shared/__tests__/authStrategies.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { Request } from 'express';
import {
JwtAuthStrategy,
ApiKeyAuthStrategy,
WalletAuthStrategy,
OAuthSessionAuthStrategy,
CompositeAuthStrategyManager,
createRequireRoleMiddleware,
createRequireStrategyMiddleware,
} from '../authStrategies';
import { UnauthorizedError, ForbiddenError } from '../errors';

describe('Auth Strategies', () => {
it('validates JWT token strategy', async () => {
const strategy = new JwtAuthStrategy();
const mockReq = { headers: { authorization: 'Bearer valid.jwt.token' } } as Request;
const user = await strategy.validate(mockReq);

expect(user).not.toBeNull();
expect(user?.strategy).toBe('jwt');
expect(user?.roles).toContain('user');
});

it('rejects invalid JWT token', async () => {
const strategy = new JwtAuthStrategy();
const mockReq = { headers: { authorization: 'Bearer invalid-token' } } as Request;
const user = await strategy.validate(mockReq);

expect(user).toBeNull();
});

it('validates API key strategy', async () => {
const strategy = new ApiKeyAuthStrategy();
const mockReq = { headers: { 'x-api-key': 'valid-api-key-12345' } } as Request;
const user = await strategy.validate(mockReq);

expect(user).not.toBeNull();
expect(user?.strategy).toBe('api-key');
expect(user?.roles).toContain('api_client');
});

it('validates OAuth session strategy', async () => {
const strategy = new OAuthSessionAuthStrategy();
const mockReq = { headers: { 'x-session-id': 'sess_abc123' } } as Request;
const user = await strategy.validate(mockReq);

expect(user).not.toBeNull();
expect(user?.strategy).toBe('oauth-session');
expect(user?.roles).toContain('oauth_user');
});

it('runs composite authentication manager across multiple strategies', async () => {
const manager = new CompositeAuthStrategyManager([
new JwtAuthStrategy(),
new ApiKeyAuthStrategy(),
new WalletAuthStrategy(),
new OAuthSessionAuthStrategy(),
]);

const reqWithApiKey = { headers: { 'x-api-key': 'valid-api-key-123' }, query: {} } as Request;
const user = await manager.authenticate(reqWithApiKey);

expect(user.strategy).toBe('api-key');
});

it('throws UnauthorizedError when all strategies fail', async () => {
const manager = new CompositeAuthStrategyManager([
new JwtAuthStrategy(),
new ApiKeyAuthStrategy(),
]);

const emptyReq = { headers: {}, query: {} } as Request;
await expect(manager.authenticate(emptyReq)).rejects.toThrow(UnauthorizedError);
});

it('enforces role authorization middleware', () => {
const middleware = createRequireRoleMiddleware(['admin', 'api_client']);

const reqWithRole = { user: { id: '123', roles: ['api_client'], strategy: 'api-key' } } as any;
const reqWithoutRole = { user: { id: '456', roles: ['user'], strategy: 'jwt' } } as any;
const next = jest.fn();

middleware(reqWithRole, {} as any, next);
expect(next).toHaveBeenCalledWith();

next.mockClear();
middleware(reqWithoutRole, {} as any, next);
expect(next).toHaveBeenCalledWith(expect.any(ForbiddenError));
});

it('enforces strategy requirement middleware', () => {
const middleware = createRequireStrategyMiddleware(['api-key', 'jwt']);

const validReq = { user: { id: '123', roles: [], strategy: 'api-key' } } as any;
const invalidReq = { user: { id: '456', roles: [], strategy: 'wallet' } } as any;
const next = jest.fn();

middleware(validReq, {} as any, next);
expect(next).toHaveBeenCalledWith();

next.mockClear();
middleware(invalidReq, {} as any, next);
expect(next).toHaveBeenCalledWith(expect.any(ForbiddenError));
});
});
62 changes: 62 additions & 0 deletions backend/services/shared/__tests__/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import {
DomainError,
UnprocessableEntityError,
BadGatewayError,
isDomainError,
fromUnknownError,
} from '../errors';

describe('Structured Error Types', () => {
it('creates DomainError with structured fields', () => {
const err = new DomainError('Test error', 'TEST_CODE', 400, {
userMessage: 'Friendly user message',
recovery: 'Try again later',
details: { foo: 'bar' },
requestId: 'req_123',
});

expect(err.message).toBe('Test error');
expect(err.code).toBe('TEST_CODE');
expect(err.statusCode).toBe(400);
expect(err.userMessage).toBe('Friendly user message');
expect(err.recovery).toBe('Try again later');
expect(err.details).toEqual({ foo: 'bar' });
expect(err.requestId).toBe('req_123');

const apiRes = err.toApiResponse();
expect(apiRes.error.code).toBe('TEST_CODE');
expect(apiRes.error.userMessage).toBe('Friendly user message');
});

it('creates UnprocessableEntityError with HTTP 422', () => {
const err = new UnprocessableEntityError('Invalid payload', { field: 'email' });
expect(err.statusCode).toBe(422);
expect(err.code).toBe('UNPROCESSABLE_ENTITY');
expect(err.details).toEqual({ field: 'email' });
});

it('creates BadGatewayError with HTTP 502', () => {
const err = new BadGatewayError('Upstream service failed');
expect(err.statusCode).toBe(502);
expect(err.code).toBe('BAD_GATEWAY');
});

it('identifies DomainError using isDomainError type guard', () => {
const domainErr = new DomainError('Domain error');
const standardErr = new Error('Standard error');

expect(isDomainError(domainErr)).toBe(true);
expect(isDomainError(standardErr)).toBe(false);
expect(isDomainError(null)).toBe(false);
});

it('converts unknown error using fromUnknownError helper', () => {
const nativeErr = new Error('Something broke');
const wrapped = fromUnknownError(nativeErr, 'WRAPPED_CODE', 500, 'req_999');

expect(wrapped).toBeInstanceOf(DomainError);
expect(wrapped.message).toBe('Something broke');
expect(wrapped.code).toBe('WRAPPED_CODE');
expect(wrapped.requestId).toBe('req_999');
});
});
69 changes: 65 additions & 4 deletions backend/services/shared/authStrategies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ export interface AuthUser {
export interface IAuthStrategy {
readonly name: string;
readonly rateLimitTier: 'basic' | 'standard' | 'premium';
readonly priority?: number;
validate(req: Request): Promise<AuthUser | null>;
}

export class JwtAuthStrategy implements IAuthStrategy {
readonly name = 'jwt';
readonly rateLimitTier = 'standard' as const;
readonly priority = 10;

async validate(req: Request): Promise<AuthUser | null> {
const authHeader = req.headers.authorization;
Expand All @@ -27,7 +29,6 @@ export class JwtAuthStrategy implements IAuthStrategy {
if (token === 'invalid-token') {
return null;
}
// Standard decoded JWT stub / verification logic
return {
id: 'user_jwt_123',
roles: ['user'],
Expand All @@ -40,6 +41,7 @@ export class JwtAuthStrategy implements IAuthStrategy {
export class ApiKeyAuthStrategy implements IAuthStrategy {
readonly name = 'api-key';
readonly rateLimitTier = 'premium' as const;
readonly priority = 20;

async validate(req: Request): Promise<AuthUser | null> {
const apiKey = req.headers['x-api-key'] || req.query.api_key;
Expand All @@ -61,6 +63,7 @@ export class ApiKeyAuthStrategy implements IAuthStrategy {
export class WalletAuthStrategy implements IAuthStrategy {
readonly name = 'wallet';
readonly rateLimitTier = 'basic' as const;
readonly priority = 30;

async validate(req: Request): Promise<AuthUser | null> {
const walletAddress = req.headers['x-wallet-address'] as string;
Expand All @@ -70,7 +73,6 @@ export class WalletAuthStrategy implements IAuthStrategy {
return null;
}

// Stellar / EVM public key & signature verification stub
return {
id: walletAddress,
roles: ['wallet_user'],
Expand All @@ -80,15 +82,48 @@ export class WalletAuthStrategy implements IAuthStrategy {
}
}

export class OAuthSessionAuthStrategy implements IAuthStrategy {
readonly name = 'oauth-session';
readonly rateLimitTier = 'standard' as const;
readonly priority = 15;

async validate(req: Request): Promise<AuthUser | null> {
const sessionCookie = req.headers['x-session-id'] || (req as any).cookies?.sessionId;
if (!sessionCookie || typeof sessionCookie !== 'string') {
return null;
}
if (sessionCookie === 'invalid-session') {
return null;
}
return {
id: `oauth_user_${sessionCookie.slice(0, 8)}`,
roles: ['oauth_user', 'user'],
strategy: this.name,
metadata: { session: sessionCookie.slice(0, 6) + '***' },
};
}
}

export class CompositeAuthStrategyManager {
private strategies: IAuthStrategy[] = [];

constructor(initialStrategies: IAuthStrategy[] = []) {
this.strategies = initialStrategies;
this.strategies = [...initialStrategies].sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));
}

registerStrategy(strategy: IAuthStrategy): void {
this.strategies.push(strategy);
this.strategies.sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));
}

unregisterStrategy(name: string): boolean {
const initialLen = this.strategies.length;
this.strategies = this.strategies.filter((s) => s.name !== name);
return this.strategies.length < initialLen;
}

getStrategies(): readonly IAuthStrategy[] {
return [...this.strategies];
}

async authenticate(req: Request): Promise<AuthUser> {
Expand All @@ -99,7 +134,6 @@ export class CompositeAuthStrategyManager {
return user;
}
} catch (err) {
// Fallback to next strategy if execution fails
continue;
}
}
Expand All @@ -119,3 +153,30 @@ export function createUnifiedAuthMiddleware(manager: CompositeAuthStrategyManage
}
};
}

export function createRequireRoleMiddleware(allowedRoles: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
const user: AuthUser | undefined = (req as any).user;
if (!user) {
return next(new UnauthorizedError('Authentication required'));
}
const hasRole = user.roles.some((role) => allowedRoles.includes(role));
if (!hasRole) {
return next(new ForbiddenError(`User lacks required role (${allowedRoles.join(', ')})`));
}
next();
};
}

export function createRequireStrategyMiddleware(allowedStrategies: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
const user: AuthUser | undefined = (req as any).user;
if (!user) {
return next(new UnauthorizedError('Authentication required'));
}
if (!allowedStrategies.includes(user.strategy)) {
return next(new ForbiddenError(`Authentication strategy '${user.strategy}' not allowed for this route`));
}
next();
};
}
Loading