feat: add connector owner password auth

This commit is contained in:
2026-08-03 14:23:14 +01:00
parent fb13120c94
commit f8b1724add
5 changed files with 32 additions and 5 deletions
+4 -1
View File
@@ -1,4 +1,7 @@
APP_HOST=cleanstream-connector.test APP_HOST=cleanstream-connector.test
SHARED_NETWORK=local-proxy SHARED_NETWORK=local-proxy
MEDIA_ROOT=/absolute/path/to/video-library MEDIA_ROOT=/absolute/path/to/video-library
PAIRING_SECRET=replace-with-a-long-random-secret # Production: generate with `node hash-password.js <password>`.
PAIRING_PASSWORD_HASH=
# Staging only. Do not set alongside PAIRING_PASSWORD_HASH.
PAIRING_SECRET=246810
+5
View File
@@ -11,6 +11,11 @@ For production, place the service behind HTTPS and set `PUBLIC_BASE_URL` and
approval form protected by `PAIRING_SECRET`; replace that form with your own approval form protected by `PAIRING_SECRET`; replace that form with your own
identity provider for a production connector. identity provider for a production connector.
For a password-protected production connector, generate a local owner password
hash with `node hash-password.js <password>` and set `PAIRING_PASSWORD_HASH`.
`PAIRING_SECRET` is a staging-only convenience PIN and must not be used for a
shared deployment.
## Local OrbStack development ## Local OrbStack development
Copy `.env.example` to `.env`, set `MEDIA_ROOT` to an absolute directory with Copy `.env.example` to `.env`, set `MEDIA_ROOT` to an absolute directory with
+4 -2
View File
@@ -6,13 +6,15 @@ services:
command: ["node", "--watch", "server.js"] command: ["node", "--watch", "server.js"]
environment: environment:
PORT: 8787 PORT: 8787
PUBLIC_BASE_URL: https://${APP_HOST} PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-https://${APP_HOST}}
APPROVAL_BASE_URL: https://${APP_HOST} APPROVAL_BASE_URL: ${APPROVAL_BASE_URL:-https://${APP_HOST}}
PAIRING_SECRET: ${PAIRING_SECRET} PAIRING_SECRET: ${PAIRING_SECRET}
MEDIA_ROOT: /media MEDIA_ROOT: /media
volumes: volumes:
- ./:/app - ./:/app
- ${MEDIA_ROOT:?Set MEDIA_ROOT in .env}:/media:ro - ${MEDIA_ROOT:?Set MEDIA_ROOT in .env}:/media:ro
ports:
- "127.0.0.1:8787:8787"
labels: labels:
caddy: ${APP_HOST} caddy: ${APP_HOST}
caddy.tls: internal caddy.tls: internal
+7
View File
@@ -0,0 +1,7 @@
import crypto from 'node:crypto';
const password = process.argv[2];
if (!password) throw new Error('Usage: node hash-password.js <password>');
const salt = crypto.randomBytes(16).toString('base64url');
const hash = crypto.scryptSync(password, salt, 32).toString('base64url');
console.log(`${salt}:${hash}`);
+12 -2
View File
@@ -8,8 +8,9 @@ const mediaRoot = path.resolve(process.env.MEDIA_ROOT || './media');
const publicBaseUrl = (process.env.PUBLIC_BASE_URL || `http://localhost:${port}`).replace(/\/$/, ''); const publicBaseUrl = (process.env.PUBLIC_BASE_URL || `http://localhost:${port}`).replace(/\/$/, '');
const approvalBaseUrl = (process.env.APPROVAL_BASE_URL || publicBaseUrl).replace(/\/$/, ''); const approvalBaseUrl = (process.env.APPROVAL_BASE_URL || publicBaseUrl).replace(/\/$/, '');
const pairingSecret = process.env.PAIRING_SECRET; const pairingSecret = process.env.PAIRING_SECRET;
const pairingPasswordHash = process.env.PAIRING_PASSWORD_HASH;
const demoStreamUrl = process.env.DEMO_STREAM_URL; const demoStreamUrl = process.env.DEMO_STREAM_URL;
if (!pairingSecret) throw new Error('PAIRING_SECRET is required'); if (!pairingSecret && !pairingPasswordHash) throw new Error('PAIRING_PASSWORD_HASH or PAIRING_SECRET is required');
const sessions = new Map(); const sessions = new Map();
const tokens = new Map(); const tokens = new Map();
@@ -24,6 +25,15 @@ const readJson = req => new Promise((resolve, reject) => { let raw = ''; req.on(
const bearer = req => req.headers.authorization?.replace(/^Bearer\s+/i, ''); const bearer = req => req.headers.authorization?.replace(/^Bearer\s+/i, '');
const authenticated = req => tokens.has(bearer(req)); const authenticated = req => tokens.has(bearer(req));
const id = () => crypto.randomBytes(24).toString('base64url'); const id = () => crypto.randomBytes(24).toString('base64url');
const passwordMatches = value => {
if (pairingPasswordHash) {
const [salt, expected] = pairingPasswordHash.split(':');
if (!salt || !expected) return false;
const actual = crypto.scryptSync(value, salt, 32).toString('base64url');
return crypto.timingSafeEqual(Buffer.from(actual), Buffer.from(expected));
}
return Boolean(pairingSecret) && crypto.timingSafeEqual(Buffer.from(value), Buffer.from(pairingSecret));
};
http.createServer(async (req, res) => { http.createServer(async (req, res) => {
const url = new URL(req.url, publicBaseUrl); const url = new URL(req.url, publicBaseUrl);
@@ -38,7 +48,7 @@ http.createServer(async (req, res) => {
return json(res, 200, { sessionId, approvalUri: `${approvalBaseUrl}/pair/approve?session=${sessionId}`, expiresAt: new Date(Date.now() + 300000).toISOString(), pollIntervalSeconds: 2 }); return json(res, 200, { sessionId, approvalUri: `${approvalBaseUrl}/pair/approve?session=${sessionId}`, expiresAt: new Date(Date.now() + 300000).toISOString(), pollIntervalSeconds: 2 });
} }
if (req.method === 'GET' && url.pathname === '/pair/approve') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end('<form method="post"><input name="secret" type="password" placeholder="Pairing secret"><button>Approve TV</button></form>'); } if (req.method === 'GET' && url.pathname === '/pair/approve') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end('<form method="post"><input name="secret" type="password" placeholder="Pairing secret"><button>Approve TV</button></form>'); }
if (req.method === 'POST' && url.pathname === '/pair/approve') { let raw=''; req.on('data', d => raw += d); req.on('end', () => { const session=sessions.get(url.searchParams.get('session')); if (!session || new URLSearchParams(raw).get('secret') !== pairingSecret) { res.statusCode=403; return res.end('Denied'); } session.code=id(); res.end('Approved. Return to your TV.'); }); return; } if (req.method === 'POST' && url.pathname === '/pair/approve') { let raw=''; req.on('data', d => raw += d); req.on('end', () => { const session=sessions.get(url.searchParams.get('session')); if (!session || !passwordMatches(new URLSearchParams(raw).get('secret') || '')) { res.statusCode=403; return res.end('Denied'); } session.code=id(); res.end('Approved. Return to your TV.'); }); return; }
if (req.method === 'GET' && url.pathname.startsWith('/v1/pair/')) { const session=sessions.get(url.pathname.split('/').pop()); if (!session || session.expires < Date.now()) return json(res, 200, { status: 'expired' }); return json(res, 200, session.code ? { status: 'approved', authorizationCode: session.code } : { status: 'pending' }); } if (req.method === 'GET' && url.pathname.startsWith('/v1/pair/')) { const session=sessions.get(url.pathname.split('/').pop()); if (!session || session.expires < Date.now()) return json(res, 200, { status: 'expired' }); return json(res, 200, session.code ? { status: 'approved', authorizationCode: session.code } : { status: 'pending' }); }
if (req.method === 'POST' && url.pathname === '/v1/pair/token') { const body=await readJson(req), session=sessions.get(body.sessionId); const challenge=crypto.createHash('sha256').update(body.codeVerifier || '').digest('base64url'); if (!session || session.code !== body.authorizationCode || session.challenge !== challenge) return json(res, 403, { error: 'Invalid pairing exchange' }); const token=id(); tokens.set(token, Date.now()+2592000000); sessions.delete(body.sessionId); return json(res, 200, { accessToken: token, expiresAt: new Date(tokens.get(token)).toISOString(), scopes: ['catalog','stream'] }); } if (req.method === 'POST' && url.pathname === '/v1/pair/token') { const body=await readJson(req), session=sessions.get(body.sessionId); const challenge=crypto.createHash('sha256').update(body.codeVerifier || '').digest('base64url'); if (!session || session.code !== body.authorizationCode || session.challenge !== challenge) return json(res, 403, { error: 'Invalid pairing exchange' }); const token=id(); tokens.set(token, Date.now()+2592000000); sessions.delete(body.sessionId); return json(res, 200, { accessToken: token, expiresAt: new Date(tokens.get(token)).toISOString(), scopes: ['catalog','stream'] }); }
if (req.method === 'GET' && url.pathname === '/v1/catalog') return authenticated(req) ? json(res, 200, { items: videos() }) : json(res, 401, { error: 'Unauthorized' }); if (req.method === 'GET' && url.pathname === '/v1/catalog') return authenticated(req) ? json(res, 200, { items: videos() }) : json(res, 401, { error: 'Unauthorized' });