A cryptographically-verified CF Access JWT (signature vs team JWKS + audience + email allow-list) now counts as the owner, so browser requests through the CF tunnel don't need the owner token copied onto each device. Fails closed → owner token remains the fallback (LAN-direct + dev/tests unaffected). Opt-in via CF_ACCESS_TEAM_DOMAIN / CF_ACCESS_AUD / CF_ACCESS_OWNER_EMAILS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
43 lines
1.5 KiB
JavaScript
43 lines
1.5 KiB
JavaScript
import * as agents from '../../db/repos/agents.js';
|
|
import { timingSafeStrEqual } from '../../auth/safe_compare.js';
|
|
import { accessOwnerEmail } from '../../auth/cf_access.js';
|
|
|
|
export async function agentOrOwner(req, res, next) {
|
|
const expectedOwner = process.env.OWNER_TOKEN;
|
|
if (!expectedOwner) {
|
|
return res.status(500).json({
|
|
error: { code: 'no_owner_token', message: 'OWNER_TOKEN not configured' }
|
|
});
|
|
}
|
|
// A cryptographically-verified Cloudflare Access identity counts as the owner,
|
|
// so requests through the CF tunnel don't need the owner token. Fails closed
|
|
// (null on any miss/error) → falls through to bearer-token auth below.
|
|
const cfEmail = await accessOwnerEmail(req);
|
|
if (cfEmail) {
|
|
req.actor = { kind: 'user', id: null };
|
|
return next();
|
|
}
|
|
const auth = req.headers.authorization || '';
|
|
const [scheme, token] = auth.split(' ');
|
|
if (scheme !== 'Bearer' || !token) {
|
|
return res.status(401).json({ error: { code: 'unauthorized', message: 'missing bearer token' } });
|
|
}
|
|
if (timingSafeStrEqual(token, expectedOwner)) {
|
|
req.actor = { kind: 'user', id: null };
|
|
return next();
|
|
}
|
|
try {
|
|
const agent = await agents.verifyToken(token);
|
|
if (!agent) {
|
|
return res.status(401).json({ error: { code: 'unauthorized', message: 'invalid token' } });
|
|
}
|
|
req.actor = {
|
|
kind: 'agent',
|
|
id: agent.id,
|
|
capabilities: agent.capabilities || {},
|
|
scopes: agent.scopes || {}
|
|
};
|
|
next();
|
|
} catch (e) { next(e); }
|
|
}
|