From d73852a2aef78aed4e73cb89fe2427537bf57b47 Mon Sep 17 00:00:00 2001 From: Alexander Bjorneheim Date: Sun, 9 Aug 2026 11:38:23 +0200 Subject: [PATCH 1/3] =?UTF-8?q?#264:=20WIP=20=E2=80=94=20auth=20on=20GET?= =?UTF-8?q?=20/api/v1/reauth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 3f2e9bb9b36186aefacb586a080ff91baf28f20d Mon Sep 17 00:00:00 2001 From: Alexander Bjorneheim Date: Sun, 9 Aug 2026 11:53:01 +0200 Subject: [PATCH 2/3] fix(security): require bearer auth on GET /api/v1/reauth (#264) The reauth endpoint was registered with a schema only - no preHandler, no onRequest, no auth - so any unauthenticated caller could mint a valid OSC service access token. - Add requireReAuth, mirroring requireWhipAuth in api_whip.ts: Bearer header, constant-time timingSafeEqual comparison, 401 + WWW-Authenticate: Bearer realm="reauth", and auth disabled when no key is configured (existing installations keep working). - Configure via REAUTH_AUTH_KEY, falling back to WHIP_AUTH_KEY. - Defense in depth: stop returning the token in the JSON response body; the httpOnly cookie remains the delivery path. Closes #264 --- readme.md | 27 ++++----- src/api.ts | 7 ++- src/api_re_auth.test.ts | 118 +++++++++++++++++++++++++++++++++++----- src/api_re_auth.ts | 48 +++++++++++++++- src/models.ts | 5 +- src/server.ts | 1 + 6 files changed, 173 insertions(+), 33 deletions(-) diff --git a/readme.md b/readme.md index 8e5a711..249bd97 100644 --- a/readme.md +++ b/readme.md @@ -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 diff --git a/src/api.ts b/src/api.ts index 6535f7b..7a1d520 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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'; @@ -62,7 +62,8 @@ export interface ApiGeneralOptions { export type ApiOptions = ApiGeneralOptions & ApiProductionsOptions & ApiWhipOptions & - ApiWhepOptions; + ApiWhepOptions & + ApiReAuthOptions; export default async (opts: ApiOptions) => { const api = fastify({ @@ -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) => { diff --git a/src/api_re_auth.test.ts b/src/api_re_auth.test.ts index 5cc2287..a5987a5 100644 --- a/src/api_re_auth.test.ts +++ b/src/api_re_auth.test.ts @@ -79,25 +79,117 @@ 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('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); }); }); diff --git a/src/api_re_auth.ts b/src/api_re_auth.ts index fb1adcb..e637904 100644 --- a/src/api_re_auth.ts +++ b/src/api_re_auth.ts @@ -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'; @@ -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 = ( + fastify, + opts, + next +) => { + const reAuthKey = opts.reAuthKey?.trim(); + + async function requireReAuth(request: any, reply: any): Promise { + 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', { @@ -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 = { @@ -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( diff --git a/src/models.ts b/src/models.ts index b38f1ea..cc278cf 100644 --- a/src/models.ts +++ b/src/models.ts @@ -339,7 +339,10 @@ export const ShareResponse = Type.Object({ export type ShareResponse = Static; 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; diff --git a/src/server.ts b/src/server.ts index 92739ad..14af73e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -49,6 +49,7 @@ if (dbUrl.protocol === 'mongodb:' || dbUrl.protocol === 'mongodb+srv:') { smbServerApiKey: process.env.SMB_APIKEY, publicHost: PUBLIC_HOST, whipAuthKey: process.env.WHIP_AUTH_KEY, + reAuthKey: process.env.REAUTH_AUTH_KEY ?? process.env.WHIP_AUTH_KEY, dbManager: dbManager, productionManager: productionManager, ingestManager: ingestManager, From ef7030492bc862c17a5618b61f53ba9e6decb637 Mon Sep 17 00:00:00 2001 From: Alexander Bjorneheim Date: Sun, 9 Aug 2026 11:59:57 +0200 Subject: [PATCH 3/3] fix(security): warn at startup when /reauth is unauthenticated (#264) QA review of #283: auth-off-by-default is the right call for backwards compatibility, but it must not be silent. An install with OSC_ACCESS_TOKEN set and no effective key still hands out a service access token with no signal at all. A whitespace-only REAUTH_AUTH_KEY is worse: it looks configured but is falsy after trim, so auth is off while the operator believes it is on - the warning distinguishes that case as a configuration error. Also adds 401 coverage for empty Bearer, malformed header without the Bearer prefix, and a token that is a proper prefix of the key. --- src/api_re_auth.test.ts | 42 +++++++++++++++++++++++++++++++++++++++++ src/server.ts | 15 ++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/api_re_auth.test.ts b/src/api_re_auth.test.ts index a5987a5..e36f325 100644 --- a/src/api_re_auth.test.ts +++ b/src/api_re_auth.test.ts @@ -147,6 +147,48 @@ describe('reAuth api', () => { 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'); diff --git a/src/server.ts b/src/server.ts index 14af73e..d3a9cc5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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'; @@ -49,7 +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: process.env.REAUTH_AUTH_KEY ?? process.env.WHIP_AUTH_KEY, + reAuthKey: REAUTH_AUTH_KEY, dbManager: dbManager, productionManager: productionManager, ingestManager: ingestManager,