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
3 changes: 3 additions & 0 deletions packages/reverse-proxy-service/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,6 @@ KEYCLOAK_SERVER_URL=
KEYCLOAK_ORIGINAL_HOST=
KEYCLOAK_SECRET=
KEYCLOAK_PUBKEY=

## User blacklist (optional, loaded once at startup)
BLACKLIST_FILE_PATH=blacklist.txt
1 change: 1 addition & 0 deletions packages/reverse-proxy-service/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ dist/
build/

tmp/
blacklist.txt

# Logs
logs
Expand Down
24 changes: 24 additions & 0 deletions packages/reverse-proxy-service/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,30 @@ Currently, the reverse-proxy contains middleware rules for:
- CouchDB: An open-source document-oriented NoSQL database.
- Keycloak Auth: An auth middleware to apply Keycloak SSO Auth to some restricted URLs
- A no-cors proxy middleware: Used for API Catalog
- User blacklist: Optional file-backed blocklist checked before upstream proxy

## User blacklist

When `BLACKLIST_FILE_PATH` is set, the service loads a text file of blocked user identifiers once at startup. Restart the service to pick up file changes.

### File format

See [`blacklist.example.txt`](blacklist.example.txt). One value per line; empty lines and lines starting with `#` are ignored. Each line is matched if it equals the user's `uid` or `email` (case-insensitive) from their token.

### How identity is resolved

| Route | Identity source |
|-------|-----------------|
| `/api/couchdb`, `/api/no-cors-proxy` | `Authorization: Bearer` access token (verified with Keycloak public key) |
| SPA catch-all (`GET *`) | OIDC session `idToken` when the user is logged in |

Requests without a verifiable identity are not blocked by the blacklist (other auth rules may still apply).

Blocked users receive `403` with `{ "error": "Access denied" }`.

### Deployment

Mount the blacklist file at the path given by `BLACKLIST_FILE_PATH` (for example via a Kubernetes ConfigMap).

## License

Expand Down
3 changes: 3 additions & 0 deletions packages/reverse-proxy-service/blacklist.example.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# One identifier per line (uid or email)
jdoe
blocked.user@redhat.com
23 changes: 23 additions & 0 deletions packages/reverse-proxy-service/src/blacklist/blacklist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { BLACKLIST_FILE_PATH } from '../setup/env';
import { loadBlacklistFromFile } from './loadBlacklistFromFile';
import { BlacklistIndex } from './types';

const emptyIndex = (): BlacklistIndex => ({ entries: new Set<string>() });

let blacklistIndex: BlacklistIndex = emptyIndex();

export function isBlacklistEnabled(): boolean {
return Boolean(BLACKLIST_FILE_PATH?.trim());
}

export function getBlacklistIndex(): BlacklistIndex {
return blacklistIndex;
}

export async function initBlacklist(): Promise<void> {
if (!isBlacklistEnabled()) {
blacklistIndex = emptyIndex();
return;
}
blacklistIndex = await loadBlacklistFromFile(BLACKLIST_FILE_PATH as string);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { UserClaims } from './types';

export function extractUserClaims(payload: Record<string, unknown>): UserClaims {
return {
uid: typeof payload.uid === 'string' ? payload.uid : undefined,
email: typeof payload.email === 'string' ? payload.email : undefined,
};
}
24 changes: 24 additions & 0 deletions packages/reverse-proxy-service/src/blacklist/isUserBlacklisted.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import logger from '../setup/logger';
import { BlacklistIndex, UserClaims } from './types';

export function isUserBlacklisted(
index: BlacklistIndex,
user: UserClaims,
): boolean {
const { entries } = index;
if (entries.size === 0) {
return false;
}

if (user.uid && entries.has(user.uid)) {
logger.info('user is blacklisted by uid', { user });
return true;
}

if (user.email && entries.has(user.email.toLowerCase())) {
logger.info('user is blacklisted by email', { user });
return true;
}

return false;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { readFile } from 'fs/promises';
import logger from '../setup/logger';
import { parseBlacklistFile } from './parseBlacklistFile';
import { BlacklistIndex } from './types';

const emptyIndex = (): BlacklistIndex => ({ entries: new Set<string>() });

export async function loadBlacklistFromFile(
path: string
): Promise<BlacklistIndex> {
try {
const content = await readFile(path, 'utf8');
const index = parseBlacklistFile(content);
logger.info(`blacklist loaded: ${index.entries.size} entries`);
return index;
} catch (err) {
logger.error('failed to load blacklist file', { path, err });
return emptyIndex();
}
}
15 changes: 15 additions & 0 deletions packages/reverse-proxy-service/src/blacklist/parseBlacklistFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { BlacklistIndex } from './types';

export function parseBlacklistFile(content: string): BlacklistIndex {
const entries = new Set<string>();

for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed.length === 0 || trimmed.startsWith('#')) {
continue;
}
entries.add(trimmed);
}

return { entries };
}
8 changes: 8 additions & 0 deletions packages/reverse-proxy-service/src/blacklist/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export type BlacklistIndex = {
entries: Set<string>;
};

export type UserClaims = {
uid?: string;
email?: string;
};
46 changes: 46 additions & 0 deletions packages/reverse-proxy-service/src/middleware/blacklistBearer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { NextFunction, Request, Response } from 'express';
import {
getBlacklistIndex,
isBlacklistEnabled,
} from '../blacklist/blacklist';
import { extractUserClaims } from '../blacklist/extractUserClaims';
import { isUserBlacklisted } from '../blacklist/isUserBlacklisted';
import logger from '../setup/logger';
import { parseBearerToken } from '../utils/parseBearerToken';
import { verifyJwtToken } from '../utils/verifyJwtToken';

const blacklistBearer = (
req: Request,
res: Response,
next: NextFunction
): void => {
if (!isBlacklistEnabled()) {
next();
return;
}

const token = parseBearerToken(req.headers?.authorization);
if (!token) {
next();
return;
}

verifyJwtToken(token, (err: Error | null, tokenParsed: Record<string, unknown>) => {
if (err) {
next();
return;
}

const claims = extractUserClaims(tokenParsed);
if (isUserBlacklisted(getBlacklistIndex(), claims)) {
res.status(403).json({ error: 'Access denied' });
return;
}

res.locals.user = tokenParsed;
res.locals.authenticated = true;
next();
});
};

export default blacklistBearer;
51 changes: 51 additions & 0 deletions packages/reverse-proxy-service/src/middleware/blacklistOidc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { NextFunction, Request, Response } from 'express';
import { extractUserClaims } from '../blacklist/extractUserClaims';
import {
getBlacklistIndex,
isBlacklistEnabled,
} from '../blacklist/blacklist';
import { isUserBlacklisted } from '../blacklist/isUserBlacklisted';
import logger from '../setup/logger';
import { verifyJwtToken } from '../utils/verifyJwtToken';

const blacklistOidc = (
req: Request,
res: Response,
next: NextFunction
): void => {
if (!isBlacklistEnabled()) {
next();
return;
}

if (!req.oidc?.isAuthenticated()) {
next();
return;
}

const idToken = req.oidc.idToken;
if (!idToken) {
next();
return;
}

verifyJwtToken(idToken, (err: Error | null, tokenParsed: Record<string, unknown>) => {
if (err) {
logger.warn('blacklist: failed to verify OIDC idToken', {
message: err.message,
});
next();
return;
}

const claims = extractUserClaims(tokenParsed);
if (isUserBlacklisted(getBlacklistIndex(), claims)) {
res.status(403).json({ error: 'Access denied' });
return;
}

next();
});
};

export default blacklistOidc;
2 changes: 1 addition & 1 deletion packages/reverse-proxy-service/src/middleware/checkSSO.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import getApplicationCache from '../utils/applicationCache';

export default async (req: Request, res: Response, next: NextFunction) => {
const apps = await getApplicationCache();
const app = apps.find((app) => req.url.startsWith(app.path));
const app = apps?.find((app) => req.url.startsWith(app.path));

if (app && app.authenticate) {
return requiresAuth()(req, res, next);
Expand Down
32 changes: 17 additions & 15 deletions packages/reverse-proxy-service/src/middleware/jwtAuth.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,27 @@
import { NextFunction, Request, Response } from 'express';
import { parseBearerToken } from '../utils/parseBearerToken';
import { verifyJwtToken } from '../utils/verifyJwtToken';

const jwtAuth = (req: Request, res: Response, next: NextFunction): void => {
try {
if (!req.headers?.authorization) {
throw new Error('Request is not authenticated');
if (res.locals.user) {
next();
return;
}
const authorization = req.headers.authorization;
const [authType, token] = authorization.split(' ');
if (authType === 'Bearer') {
verifyJwtToken(token, (err: any, tokenParsed: any) => {
if (err) {
throw new Error(err.message);
}
res.locals.user = tokenParsed;
res.locals.authenticated = true;
next();
});
} else {
throw new Error(`Authentication method not supported`);

const token = parseBearerToken(req.headers?.authorization);
if (!token) {
throw new Error('Request is not authenticated');
}

verifyJwtToken(token, (err: any, tokenParsed: any) => {
if (err) {
throw new Error(err.message);
}
res.locals.user = tokenParsed;
res.locals.authenticated = true;
next();
});
} catch (err: any) {
res.status(403).json({ error: err.message });
}
Expand Down
3 changes: 2 additions & 1 deletion packages/reverse-proxy-service/src/restricted-spas/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Router } from 'express';
import blacklistOidc from '../middleware/blacklistOidc';
import checkSSO from '../middleware/checkSSO';
import resolver from './resolver';

Expand All @@ -13,6 +14,6 @@ router.get('/red-hat-in-vehicle-development-guide', (_, res) => {
res.redirect('/in-vehicle-development-guide', 301);
});
/* Proxy all other apps using resolver */
router.get('*', [checkSSO], resolver);
router.get('*', [checkSSO, blacklistOidc], resolver);

export default router;
8 changes: 6 additions & 2 deletions packages/reverse-proxy-service/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import cors from 'cors';
import expressWinston from 'express-winston';
import session from 'express-session';
import rateLimit from 'express-rate-limit';
import blacklistBearer from './middleware/blacklistBearer';
import jwtAuth from './middleware/jwtAuth';
import couchDBRouter from './couchdb';
import noCorsRouter from './no-cors-proxy';
Expand All @@ -11,6 +12,7 @@ import winstonInstance from './setup/logger';
import store from './setup/store';
import { COOKIE_SECRET } from './setup/env';
import oidcAuth from './middleware/oidcAuth';
import { initBlacklist } from './blacklist/blacklist';
import { updateApplicationCache } from './utils/applicationCache';

const getServer = async () => {
Expand Down Expand Up @@ -39,9 +41,11 @@ const getServer = async () => {
res.json({ message: 'This is a proxy service.' });
});

server.use('/api/couchdb', [cors(), jwtAuth], couchDBRouter);
await initBlacklist();

server.use('/api/no-cors-proxy', [cors()], noCorsRouter);
server.use('/api/couchdb', [cors(), blacklistBearer, jwtAuth], couchDBRouter);

server.use('/api/no-cors-proxy', [cors(), blacklistBearer], noCorsRouter);

/* Setting up session for keycloak */
server.use(
Expand Down
3 changes: 3 additions & 0 deletions packages/reverse-proxy-service/src/setup/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ export const { COUCHDB_HOST, COUCHDB_SECRET } = process.env;

/* SPASHIP environment variables */
export const { SPASHIP_ROUTER_HOST } = process.env;

/* User blacklist */
export const { BLACKLIST_FILE_PATH } = process.env;
Loading
Loading