Skip to content
Open
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
27 changes: 14 additions & 13 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,19 +60,20 @@ The `osc_eyevinn_intercom_manager` resource requires these variables:

## Environment variables

| Variable name | Description |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PORT` | Intercom-Manager API port (default: `8000`) |
| `SMB_ADDRESS` | The address:port of the Symphony Media Bridge instance (default: `http://localhost:8080`) |
| `SMB_APIKEY` | When set, provide this API key for the Symphony Media Bridge (optional) |
| `DB_CONNECTION_STRING` | DB connection string (default: `mongodb://localhost:27017/intercom-manager`). Supports MongoDB (`mongodb://`) and CouchDB (`http://`/`https://`) |
| `PUBLIC_HOST` | Hostname for frontend application for generating URLs to share (default: `http://localhost:8000`) |
| `CORS_ORIGIN` | Comma-separated list of allowed CORS origins, e.g. `http://localhost:5173,http://localhost:5174`. When unset, CORS is disabled. Required for local development when the frontend runs on a different port |
| `WHIP_AUTH_KEY` | When set, WHIP and WHEP endpoints require a `Bearer` token matching this key (optional) |
| `ENDPOINT_IDLE_TIMEOUT_S` | Idle timeout in seconds for SMB endpoints (default: `60`) |
| `OSC_ACCESS_TOKEN` | Personal Access Token from OSC for link sharing and reauthenticating (optional) |
| `ICE_SERVERS` | Comma-separated list of ICE servers in the format: `turn:username:password@turn.example.com,stun:stun.example.com`. If no STUN server is provided, and WHIP endpoints are used, Google's default STUN server (`stun:stun.l.google.com:19302`) will be used. |
| `MONGODB_CONNECTION_STRING` | DEPRECATED: Use `DB_CONNECTION_STRING` instead |
| Variable name | Description |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `PORT` | Intercom-Manager API port (default: `8000`) |
| `SMB_ADDRESS` | The address:port of the Symphony Media Bridge instance (default: `http://localhost:8080`) |
| `SMB_APIKEY` | When set, provide this API key for the Symphony Media Bridge (optional) |
| `DB_CONNECTION_STRING` | DB connection string (default: `mongodb://localhost:27017/intercom-manager`). Supports MongoDB (`mongodb://`) and CouchDB (`http://`/`https://`) |
| `PUBLIC_HOST` | Hostname for frontend application for generating URLs to share (default: `http://localhost:8000`) |
| `CORS_ORIGIN` | Comma-separated list of allowed CORS origins, e.g. `http://localhost:5173,http://localhost:5174`. When unset, CORS is disabled. Required for local development when the frontend runs on a different port |
| `WHIP_AUTH_KEY` | When set, WHIP and WHEP endpoints require a `Bearer` token matching this key (optional) |
| `ENDPOINT_IDLE_TIMEOUT_S` | Idle timeout in seconds for SMB endpoints (default: `60`) |
| `REAUTH_AUTH_KEY` | When set, the `GET /api/v1/reauth` endpoint requires a `Bearer` token matching this key. Defaults to `WHIP_AUTH_KEY` when unset; when neither is set the endpoint is unauthenticated (optional, but strongly recommended whenever `OSC_ACCESS_TOKEN` is set) |
| `OSC_ACCESS_TOKEN` | Personal Access Token from OSC for link sharing and reauthenticating (optional) |
| `ICE_SERVERS` | Comma-separated list of ICE servers in the format: `turn:username:password@turn.example.com,stun:stun.example.com`. If no STUN server is provided, and WHIP endpoints are used, Google's default STUN server (`stun:stun.l.google.com:19302`) will be used. |
| `MONGODB_CONNECTION_STRING` | DEPRECATED: Use `DB_CONNECTION_STRING` instead |

## Installation / Usage

Expand Down
7 changes: 4 additions & 3 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import fastify, { FastifyPluginCallback } from 'fastify';
import { getApiIngests } from './api_ingests';
import { ApiProductionsOptions, getApiProductions } from './api_productions';
import apiGroups from './api_groups';
import apiReAuth from './api_re_auth';
import apiReAuth, { ApiReAuthOptions } from './api_re_auth';
import apiShare from './api_share';
import apiWhip, { ApiWhipOptions } from './api_whip';
import apiWhep, { ApiWhepOptions } from './api_whep';
Expand Down Expand Up @@ -62,7 +62,8 @@ export interface ApiGeneralOptions {
export type ApiOptions = ApiGeneralOptions &
ApiProductionsOptions &
ApiWhipOptions &
ApiWhepOptions;
ApiWhepOptions &
ApiReAuthOptions;

export default async (opts: ApiOptions) => {
const api = fastify({
Expand Down Expand Up @@ -154,7 +155,7 @@ export default async (opts: ApiOptions) => {
smb: opts.smb
});
api.register(apiShare, { publicHost: opts.publicHost, prefix: 'api/v1' });
api.register(apiReAuth, { prefix: 'api/v1' });
api.register(apiReAuth, { prefix: 'api/v1', reAuthKey: opts.reAuthKey });
api.register(apiGroups, { prefix: 'api/v1', dbManager: opts.dbManager });

api.all('/whip/:productionId/:lineId', async (request, reply) => {
Expand Down
160 changes: 147 additions & 13 deletions src/api_re_auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,25 +79,159 @@ const mockIngestManager = {
startPolling: jest.fn()
} as any;

const baseOptions = {
title: 'my awesome service',
smbServerBaseUrl: 'http://localhost',
endpointIdleTimeout: '60',
publicHost: 'https://example.com',
dbManager: mockDbManager,
productionManager: mockProductionManager,
ingestManager: mockIngestManager
};

const createServer = (reAuthKey?: string) =>
api({
...baseOptions,
reAuthKey,
coreFunctions: new CoreFunctions(
mockProductionManager,
new ConnectionQueue()
)
});

const mockTokenService = () => {
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({ token: 'a-new-sat-token' })
});
global.fetch = fetchMock as unknown as typeof global.fetch;
return fetchMock;
};

describe('reAuth api', () => {
test('can generate a new SAT Token for the OSC Intercom instance', async () => {
const server = await api({
title: 'my awesome service',
smbServerBaseUrl: 'http://localhost',
endpointIdleTimeout: '60',
publicHost: 'https://example.com',
dbManager: mockDbManager,
productionManager: mockProductionManager,
ingestManager: mockIngestManager,
coreFunctions: new CoreFunctions(
mockProductionManager,
new ConnectionQueue()
)
const originalFetch = global.fetch;

afterEach(() => {
global.fetch = originalFetch;
jest.restoreAllMocks();
});

test('returns 401 without credentials when a reauth key is configured', async () => {
const fetchMock = mockTokenService();
const server = await createServer('secret-123');

const response = await server.inject({
method: 'GET',
url: '/api/v1/reauth'
});

expect(response.statusCode).toBe(401);
expect(response.headers['www-authenticate']).toContain('Bearer');
expect(fetchMock).not.toHaveBeenCalled();
expect(response.headers['set-cookie']).toBeUndefined();
});

test('returns 401 with a wrong bearer token', async () => {
const fetchMock = mockTokenService();
const server = await createServer('secret-123');

const response = await server.inject({
method: 'GET',
url: '/api/v1/reauth',
headers: { authorization: 'Bearer wrong-key' }
});

expect(response.statusCode).toBe(401);
expect(fetchMock).not.toHaveBeenCalled();
});

test('returns 401 with an empty bearer token', async () => {
const fetchMock = mockTokenService();
const server = await createServer('secret-123');

const response = await server.inject({
method: 'GET',
url: '/api/v1/reauth',
headers: { authorization: 'Bearer' }
});

expect(response.statusCode).toBe(401);
expect(fetchMock).not.toHaveBeenCalled();
});

test('returns 401 with a malformed authorization header (no Bearer prefix)', async () => {
const fetchMock = mockTokenService();
const server = await createServer('secret-123');

const response = await server.inject({
method: 'GET',
url: '/api/v1/reauth',
headers: { authorization: 'secret-123' }
});

expect(response.statusCode).toBe(401);
expect(fetchMock).not.toHaveBeenCalled();
});

test('returns 401 when the token is a proper prefix of the key', async () => {
const fetchMock = mockTokenService();
const server = await createServer('secret-123');

const response = await server.inject({
method: 'GET',
url: '/api/v1/reauth',
headers: { authorization: 'Bearer secret-12' }
});

expect(response.statusCode).toBe(401);
expect(fetchMock).not.toHaveBeenCalled();
});

test('generates a new SAT token with a correct bearer token', async () => {
const fetchMock = mockTokenService();
const server = await createServer('secret-123');

const response = await server.inject({
method: 'GET',
url: '/api/v1/reauth',
headers: { authorization: 'Bearer secret-123' }
});

expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(response.json()).toEqual({ success: true });
expect(response.json().token).toBeUndefined();
expect(String(response.headers['set-cookie'])).toContain(
'eyevinn-intercom-manager.sat=Bearer%20a-new-sat-token'
);
});

test('allows unauthenticated access when no reauth key is configured', async () => {
const fetchMock = mockTokenService();
const server = await createServer(undefined);

const response = await server.inject({
method: 'GET',
url: '/api/v1/reauth'
});

expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

test('returns 500 when the token service is unavailable', async () => {
global.fetch = jest
.fn()
.mockRejectedValue(new Error('network down')) as unknown as typeof fetch;
const server = await createServer(undefined);

const response = await server.inject({
method: 'GET',
url: '/api/v1/reauth'
});

expect(response.statusCode).toBe(500);
});
});
48 changes: 45 additions & 3 deletions src/api_re_auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { timingSafeEqual } from 'crypto';
import { FastifyPluginCallback } from 'fastify';
import { ErrorResponse, ReAuthResponse } from './models';

export interface ApiReAuthOptions {
reAuthKey?: string;
}

const OSC_ACCESS_TOKEN = process.env.OSC_ACCESS_TOKEN;
const OSC_ENVIRONMENT = process.env.OSC_ENVIRONMENT ?? 'prod';

Expand All @@ -9,7 +14,40 @@ const REAUTH_RETRY_DELAY_MS = 1000;

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

const apiReAuth: FastifyPluginCallback = (fastify, _, next) => {
const apiReAuth: FastifyPluginCallback<ApiReAuthOptions> = (
fastify,
opts,
next
) => {
const reAuthKey = opts.reAuthKey?.trim();

async function requireReAuth(request: any, reply: any): Promise<boolean> {
if (!reAuthKey) {
return true; // auth disabled
}

const authHeader =
request.headers['authorization'] || request.headers['Authorization'];
const prefix = 'Bearer ';

const token = authHeader?.startsWith?.(prefix)
? authHeader.slice(prefix.length).trim()
: '';
const tokenBuf = Buffer.from(token);
const keyBuf = Buffer.from(reAuthKey);
const isValid =
tokenBuf.length === keyBuf.length && timingSafeEqual(tokenBuf, keyBuf);

if (!authHeader || typeof authHeader !== 'string' || !isValid) {
reply
.header('WWW-Authenticate', 'Bearer realm="reauth", charset="UTF-8"')
.code(401)
.send({ error: 'Unauthorized' });
return false;
}
return true;
}

fastify.get(
'/reauth',
{
Expand All @@ -19,12 +57,16 @@ const apiReAuth: FastifyPluginCallback = (fastify, _, next) => {
response: {
200: ReAuthResponse,
400: ErrorResponse,
401: ErrorResponse,
405: ErrorResponse,
500: ErrorResponse
}
}
},
async (_, reply) => {
async (request, reply) => {
if (!(await requireReAuth(request, reply))) {
return;
}
if (OSC_ACCESS_TOKEN) {
const url = `https://token.svc.${OSC_ENVIRONMENT}.osaas.io/servicetoken`;
const options = {
Expand Down Expand Up @@ -56,7 +98,7 @@ const apiReAuth: FastifyPluginCallback = (fastify, _, next) => {
maxAge: 60 * 60 * 2 // 2 hours, in seconds
}
)
.send({ token: json.token });
.send({ success: true });
return;
}
lastError = new Error(
Expand Down
5 changes: 4 additions & 1 deletion src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,10 @@ export const ShareResponse = Type.Object({
export type ShareResponse = Static<typeof ShareResponse>;

export const ReAuthResponse = Type.Object({
token: Type.String({ description: 'The new OSC Service Access Token' })
success: Type.Boolean({
description:
'True when a new OSC Service Access Token was issued. The token itself is only returned as an httpOnly cookie.'
})
});
export type ReAuthResponse = Static<typeof ReAuthResponse>;

Expand Down
14 changes: 14 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ if (!process.env.SMB_ADDRESS) {
Log().warn('SMB_ADDRESS environment variable not set, using defaults');
}

const REAUTH_AUTH_KEY =
process.env.REAUTH_AUTH_KEY ?? process.env.WHIP_AUTH_KEY;

if (process.env.OSC_ACCESS_TOKEN && !REAUTH_AUTH_KEY?.trim()) {
const reason =
REAUTH_AUTH_KEY === undefined
? 'no REAUTH_AUTH_KEY or WHIP_AUTH_KEY is set'
: 'REAUTH_AUTH_KEY/WHIP_AUTH_KEY is set but empty or whitespace only, which disables auth - this is most likely a configuration error';
Log().warn(
`SECURITY: GET /api/v1/reauth is UNAUTHENTICATED - anyone who can reach this server can obtain a valid OSC service access token. Reason: ${reason}. Set REAUTH_AUTH_KEY to a non-empty secret to require a Bearer token.`
);
}

const ENDPOINT_IDLE_TIMEOUT_S: string =
process.env.ENDPOINT_IDLE_TIMEOUT_S ?? '60';

Expand Down Expand Up @@ -49,6 +62,7 @@ if (dbUrl.protocol === 'mongodb:' || dbUrl.protocol === 'mongodb+srv:') {
smbServerApiKey: process.env.SMB_APIKEY,
publicHost: PUBLIC_HOST,
whipAuthKey: process.env.WHIP_AUTH_KEY,
reAuthKey: REAUTH_AUTH_KEY,
dbManager: dbManager,
productionManager: productionManager,
ingestManager: ingestManager,
Expand Down
Loading