Skip to content

Commit d8bbcee

Browse files
authored
bridge: support broker agent-token auth for send path (#129)
1 parent d57f2c7 commit d8bbcee

8 files changed

Lines changed: 244 additions & 2 deletions

File tree

.env.schema

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,18 @@ SLACK_BROKER_PUBLIC_KEY=
136136
# @sensitive=false @type=string
137137
SLACK_BROKER_SIGNING_PUBLIC_KEY=
138138

139+
# Optional broker-issued bearer token for broker API auth
140+
# @type=string
141+
SLACK_BROKER_ACCESS_TOKEN=
142+
143+
# Optional broker token expiration timestamp (ISO-8601)
144+
# @sensitive=false @type=string
145+
SLACK_BROKER_ACCESS_TOKEN_EXPIRES_AT=
146+
147+
# Optional broker token scopes (comma-separated)
148+
# @sensitive=false @type=string
149+
SLACK_BROKER_ACCESS_TOKEN_SCOPES=
150+
139151
# Broker pull cadence in milliseconds (default: 3000)
140152
# @sensitive=false @type=number
141153
SLACK_BROKER_POLL_INTERVAL_MS=3000

CONFIGURATION.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ Set by `sudo baudbot broker register` when using brokered Slack OAuth flow.
104104
| `SLACK_BROKER_SERVER_SIGNING_PUBLIC_KEY` | Server Ed25519 public signing key (base64) |
105105
| `SLACK_BROKER_PUBLIC_KEY` | Broker X25519 public key (base64) |
106106
| `SLACK_BROKER_SIGNING_PUBLIC_KEY` | Broker Ed25519 public signing key (base64) |
107+
| `SLACK_BROKER_ACCESS_TOKEN` | Optional broker-issued bearer token for broker API auth (used when broker enforces agent tokens) |
108+
| `SLACK_BROKER_ACCESS_TOKEN_EXPIRES_AT` | Optional ISO timestamp for broker token expiry |
109+
| `SLACK_BROKER_ACCESS_TOKEN_SCOPES` | Optional comma-separated broker token scopes |
107110
| `SLACK_BROKER_POLL_INTERVAL_MS` | Inbox poll interval in milliseconds (default: `3000`) |
108111
| `SLACK_BROKER_MAX_MESSAGES` | Max leased messages per poll request (default: `10`) |
109112
| `SLACK_BROKER_WAIT_SECONDS` | Long-poll wait window for `/api/inbox/pull` (default: `20`, set `0` for immediate short-poll, max `25`) |
@@ -189,6 +192,10 @@ SENTRY_CHANNEL_ID=C0987654321
189192
# Slack broker registration (optional, set by: sudo baudbot broker register)
190193
SLACK_BROKER_URL=https://broker.example.com
191194
SLACK_BROKER_WORKSPACE_ID=T0123ABCD
195+
# Optional broker auth token fields (set by broker register when provided)
196+
# SLACK_BROKER_ACCESS_TOKEN=...
197+
# SLACK_BROKER_ACCESS_TOKEN_EXPIRES_AT=2026-02-22T22:15:00.000Z
198+
# SLACK_BROKER_ACCESS_TOKEN_SCOPES=slack.send,inbox.pull,inbox.ack
192199
SLACK_BROKER_POLL_INTERVAL_MS=3000
193200
SLACK_BROKER_MAX_MESSAGES=10
194201
SLACK_BROKER_WAIT_SECONDS=20

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ sudo baudbot broker register \
9999
```
100100

101101
Broker pull mode uses long-polling by default (`SLACK_BROKER_WAIT_SECONDS=20`, max `25`; set `0` for immediate short-poll behavior).
102+
When broker agent-token auth is enabled server-side, `baudbot broker register` stores broker token fields in env and broker-mode outbound requests include `Authorization: Bearer ...` automatically.
102103

103104
Need to rotate/update a key later?
104105

bin/broker-register.mjs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,11 @@ export async function registerWithBroker({
350350
return {
351351
broker_pubkey: registerBrokerPubkey || fetchedBrokerKeys.broker_pubkey,
352352
broker_signing_pubkey: registerBrokerSigningPubkey || fetchedBrokerKeys.broker_signing_pubkey,
353+
broker_access_token: body?.broker_access_token,
354+
broker_access_token_expires_at: body?.broker_access_token_expires_at,
355+
broker_access_token_scopes: Array.isArray(body?.broker_access_token_scopes)
356+
? body.broker_access_token_scopes.filter((scope) => typeof scope === "string")
357+
: undefined,
353358
decrypted_bot_token: decryptedBotToken,
354359
request_payload: payload,
355360
};
@@ -573,6 +578,16 @@ export async function runRegistration({
573578
SLACK_BROKER_SIGNING_PUBLIC_KEY: registration.broker_signing_pubkey,
574579
};
575580

581+
if (registration.broker_access_token) {
582+
updates.SLACK_BROKER_ACCESS_TOKEN = registration.broker_access_token;
583+
}
584+
if (registration.broker_access_token_expires_at) {
585+
updates.SLACK_BROKER_ACCESS_TOKEN_EXPIRES_AT = registration.broker_access_token_expires_at;
586+
}
587+
if (registration.broker_access_token_scopes?.length) {
588+
updates.SLACK_BROKER_ACCESS_TOKEN_SCOPES = registration.broker_access_token_scopes.join(",");
589+
}
590+
576591
// Add the decrypted bot token if available
577592
if (registration.decrypted_bot_token) {
578593
updates.SLACK_BOT_TOKEN = registration.decrypted_bot_token;

bin/broker-register.test.mjs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,9 @@ test("registerWithBroker fetches pubkeys then posts registration payload", async
129129
ok: true,
130130
broker_pubkey: Buffer.alloc(32, 9).toString("base64"),
131131
broker_signing_pubkey: Buffer.alloc(32, 8).toString("base64"),
132+
broker_access_token: "tok-abc",
133+
broker_access_token_expires_at: "2026-02-22T22:00:00.000Z",
134+
broker_access_token_scopes: ["slack.send", "inbox.pull"],
132135
});
133136
}
134137

@@ -148,6 +151,9 @@ test("registerWithBroker fetches pubkeys then posts registration payload", async
148151
assert.match(calls[1].url, /\/api\/register$/);
149152
assert.equal(result.broker_pubkey, Buffer.alloc(32, 9).toString("base64"));
150153
assert.equal(result.broker_signing_pubkey, Buffer.alloc(32, 8).toString("base64"));
154+
assert.equal(result.broker_access_token, "tok-abc");
155+
assert.equal(result.broker_access_token_expires_at, "2026-02-22T22:00:00.000Z");
156+
assert.deepEqual(result.broker_access_token_scopes, ["slack.send", "inbox.pull"]);
151157
});
152158

153159
test("registerWithBroker sends registration_token when provided", async () => {
@@ -201,7 +207,14 @@ test("runRegistration integration path succeeds against live local HTTP server",
201207
for await (const chunk of req) raw += chunk;
202208
receivedRegisterPayload = JSON.parse(raw);
203209
res.writeHead(200, { "Content-Type": "application/json" });
204-
res.end(JSON.stringify({ ok: true, broker_pubkey: brokerPubkey, broker_signing_pubkey: brokerSigningPubkey }));
210+
res.end(JSON.stringify({
211+
ok: true,
212+
broker_pubkey: brokerPubkey,
213+
broker_signing_pubkey: brokerSigningPubkey,
214+
broker_access_token: "tok-live",
215+
broker_access_token_expires_at: "2026-02-22T22:00:00.000Z",
216+
broker_access_token_scopes: ["slack.send"],
217+
}));
205218
return;
206219
}
207220

@@ -228,6 +241,9 @@ test("runRegistration integration path succeeds against live local HTTP server",
228241
assert.ok(result.updates.SLACK_BROKER_SERVER_SIGNING_PRIVATE_KEY);
229242
assert.equal(result.updates.SLACK_BROKER_PUBLIC_KEY, brokerPubkey);
230243
assert.equal(result.updates.SLACK_BROKER_SIGNING_PUBLIC_KEY, brokerSigningPubkey);
244+
assert.equal(result.updates.SLACK_BROKER_ACCESS_TOKEN, "tok-live");
245+
assert.equal(result.updates.SLACK_BROKER_ACCESS_TOKEN_EXPIRES_AT, "2026-02-22T22:00:00.000Z");
246+
assert.equal(result.updates.SLACK_BROKER_ACCESS_TOKEN_SCOPES, "slack.send");
231247
} finally {
232248
await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
233249
}

bin/config.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,9 @@ else
449449
SLACK_BROKER_SERVER_SIGNING_PUBLIC_KEY \
450450
SLACK_BROKER_PUBLIC_KEY \
451451
SLACK_BROKER_SIGNING_PUBLIC_KEY \
452+
SLACK_BROKER_ACCESS_TOKEN \
453+
SLACK_BROKER_ACCESS_TOKEN_EXPIRES_AT \
454+
SLACK_BROKER_ACCESS_TOKEN_SCOPES \
452455
SLACK_BROKER_POLL_INTERVAL_MS \
453456
SLACK_BROKER_MAX_MESSAGES \
454457
SLACK_BROKER_WAIT_SECONDS \
@@ -612,6 +615,9 @@ ordered_keys=(
612615
SLACK_BROKER_SERVER_SIGNING_PUBLIC_KEY
613616
SLACK_BROKER_PUBLIC_KEY
614617
SLACK_BROKER_SIGNING_PUBLIC_KEY
618+
SLACK_BROKER_ACCESS_TOKEN
619+
SLACK_BROKER_ACCESS_TOKEN_EXPIRES_AT
620+
SLACK_BROKER_ACCESS_TOKEN_SCOPES
615621
SLACK_BROKER_POLL_INTERVAL_MS
616622
SLACK_BROKER_MAX_MESSAGES
617623
SLACK_BROKER_WAIT_SECONDS

slack-bridge/broker-bridge.mjs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ const directSlackRateLimiter = createRateLimiter({ maxRequests: 1, windowMs: 1_0
9494

9595
const workspaceId = process.env.SLACK_BROKER_WORKSPACE_ID;
9696
const brokerBaseUrl = String(process.env.SLACK_BROKER_URL || "").replace(/\/$/, "");
97+
const brokerAccessToken = String(process.env.SLACK_BROKER_ACCESS_TOKEN || "").trim();
98+
const brokerAccessTokenExpiresAt = String(process.env.SLACK_BROKER_ACCESS_TOKEN_EXPIRES_AT || "").trim();
9799

98100
// Check if direct Slack API mode is available
99101
const hasDirectSlackToken = Boolean(process.env.SLACK_BOT_TOKEN);
@@ -109,6 +111,7 @@ let socketPath = null;
109111
let cryptoState = null;
110112

111113
const dedupe = new Map();
114+
let brokerTokenExpiryFormatWarned = false;
112115

113116
const brokerHealth = {
114117
started_at: new Date().toISOString(),
@@ -385,11 +388,37 @@ function signPullRequest(timestamp, maxMessages, waitSeconds) {
385388
});
386389
}
387390

391+
function isBrokerAccessTokenExpired() {
392+
if (!brokerAccessToken || !brokerAccessTokenExpiresAt) return false;
393+
const ts = Date.parse(brokerAccessTokenExpiresAt);
394+
if (!Number.isFinite(ts)) {
395+
if (!brokerTokenExpiryFormatWarned) {
396+
logWarn("⚠️ invalid SLACK_BROKER_ACCESS_TOKEN_EXPIRES_AT format; expected ISO-8601 timestamp");
397+
brokerTokenExpiryFormatWarned = true;
398+
}
399+
return false;
400+
}
401+
return Date.now() >= ts;
402+
}
403+
404+
function enforceBrokerTokenFreshnessOrExit() {
405+
if (!isBrokerAccessTokenExpired()) return;
406+
407+
logError("❌ broker access token is expired; broker API auth will fail.");
408+
logError(" run: sudo baudbot broker register && sudo baudbot restart");
409+
process.exit(1);
410+
}
411+
388412
async function brokerFetch(pathname, body) {
413+
enforceBrokerTokenFreshnessOrExit();
389414
const url = `${brokerBaseUrl}${pathname}`;
415+
const headers = { "Content-Type": "application/json" };
416+
if (brokerAccessToken) {
417+
headers.Authorization = `Bearer ${brokerAccessToken}`;
418+
}
390419
const response = await fetch(url, {
391420
method: "POST",
392-
headers: { "Content-Type": "application/json" },
421+
headers,
393422
body: JSON.stringify(body),
394423
});
395424

@@ -987,6 +1016,8 @@ async function startPollLoop() {
9871016
serverSignSecretKey: signKeypair.privateKey,
9881017
};
9891018

1019+
enforceBrokerTokenFreshnessOrExit();
1020+
9901021
refreshSocket();
9911022
startApiServer();
9921023
persistBrokerHealth();
@@ -995,6 +1026,7 @@ async function startPollLoop() {
9951026
logInfo(` broker: ${brokerBaseUrl}`);
9961027
logInfo(` workspace: ${workspaceId}`);
9971028
logInfo(` inbox protocol: ${INBOX_PROTOCOL_VERSION}`);
1029+
logInfo(` broker auth token: ${brokerAccessToken ? "configured" : "not configured"}`);
9981030
logInfo(
9991031
` poll mode: ${BROKER_WAIT_SECONDS > 0 ? `long-poll (${BROKER_WAIT_SECONDS}s)` : "short-poll"}, ` +
10001032
`interval: ${POLL_INTERVAL_MS}ms, max messages: ${MAX_MESSAGES}`,

test/broker-bridge.integration.test.mjs

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,24 @@ function waitFor(condition, timeoutMs = 10_000, intervalMs = 50, onTimeoutMessag
3232
});
3333
}
3434

35+
async function reserveFreePort() {
36+
const server = createServer((_req, res) => {
37+
res.writeHead(204);
38+
res.end();
39+
});
40+
41+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
42+
const address = server.address();
43+
if (!address || typeof address === "string") {
44+
await new Promise((resolve) => server.close(() => resolve(undefined)));
45+
throw new Error("failed to reserve free port");
46+
}
47+
48+
const port = address.port;
49+
await new Promise((resolve) => server.close(() => resolve(undefined)));
50+
return port;
51+
}
52+
3553
describe("broker pull bridge semi-integration", () => {
3654
const children = [];
3755
const servers = [];
@@ -621,4 +639,139 @@ describe("broker pull bridge semi-integration", () => {
621639

622640
bridge.kill("SIGTERM");
623641
});
642+
643+
it("sends broker bearer token when configured", async () => {
644+
await sodium.ready;
645+
646+
const workspaceId = "T123BROKER";
647+
const bridgeApiPort = await reserveFreePort();
648+
let outboundAuthorization = null;
649+
650+
const broker = createServer(async (req, res) => {
651+
if (req.method === "POST" && req.url === "/api/inbox/pull") {
652+
res.writeHead(200, { "Content-Type": "application/json" });
653+
res.end(JSON.stringify({ ok: true, messages: [] }));
654+
return;
655+
}
656+
657+
if (req.method === "POST" && req.url === "/api/send") {
658+
outboundAuthorization = req.headers.authorization || null;
659+
res.writeHead(200, { "Content-Type": "application/json" });
660+
res.end(JSON.stringify({ ok: true, ts: "1234.5678" }));
661+
return;
662+
}
663+
664+
if (req.method === "POST" && req.url === "/api/inbox/ack") {
665+
res.writeHead(200, { "Content-Type": "application/json" });
666+
res.end(JSON.stringify({ ok: true, acked: 0 }));
667+
return;
668+
}
669+
670+
res.writeHead(404, { "Content-Type": "application/json" });
671+
res.end(JSON.stringify({ ok: false, error: "not found" }));
672+
});
673+
674+
await new Promise((resolve) => broker.listen(0, "127.0.0.1", resolve));
675+
servers.push(broker);
676+
677+
const address = broker.address();
678+
if (!address || typeof address === "string") {
679+
throw new Error("failed to get broker test server address");
680+
}
681+
const brokerUrl = `http://127.0.0.1:${address.port}`;
682+
683+
const testFileDir = path.dirname(fileURLToPath(import.meta.url));
684+
const repoRoot = path.dirname(testFileDir);
685+
const bridgePath = path.join(repoRoot, "slack-bridge", "broker-bridge.mjs");
686+
const bridgeCwd = path.join(repoRoot, "slack-bridge");
687+
688+
const bridge = spawn("node", [bridgePath], {
689+
cwd: bridgeCwd,
690+
env: {
691+
...process.env,
692+
SLACK_BROKER_URL: brokerUrl,
693+
SLACK_BROKER_WORKSPACE_ID: workspaceId,
694+
SLACK_BROKER_SERVER_PRIVATE_KEY: b64(32, 11),
695+
SLACK_BROKER_SERVER_PUBLIC_KEY: b64(32, 12),
696+
SLACK_BROKER_SERVER_SIGNING_PRIVATE_KEY: Buffer.alloc(32, 24).toString("base64"),
697+
SLACK_BROKER_PUBLIC_KEY: b64(32, 14),
698+
SLACK_BROKER_SIGNING_PUBLIC_KEY: b64(32, 15),
699+
SLACK_BROKER_ACCESS_TOKEN: "test-broker-token",
700+
SLACK_ALLOWED_USERS: "U_ALLOWED",
701+
SLACK_BROKER_POLL_INTERVAL_MS: "50",
702+
SLACK_BROKER_WAIT_SECONDS: "0",
703+
BRIDGE_API_PORT: String(bridgeApiPort),
704+
},
705+
stdio: ["ignore", "pipe", "pipe"],
706+
});
707+
children.push(bridge);
708+
709+
const start = Date.now();
710+
// Bridge local API may not be ready immediately after spawn; retry until it accepts /send.
711+
while (Date.now() - start < 10_000) {
712+
try {
713+
const res = await fetch(`http://127.0.0.1:${bridgeApiPort}/send`, {
714+
method: "POST",
715+
headers: { "Content-Type": "application/json" },
716+
body: JSON.stringify({ channel: "C123", text: "hello" }),
717+
});
718+
if (res.ok) break;
719+
} catch {
720+
// retry while bridge boots
721+
}
722+
await new Promise((resolve) => setTimeout(resolve, 100));
723+
}
724+
725+
await waitFor(() => outboundAuthorization !== null, 10_000, 50, "timeout waiting for broker /api/send call");
726+
expect(outboundAuthorization).toBe("Bearer test-broker-token");
727+
728+
bridge.kill("SIGTERM");
729+
});
730+
731+
it("exits when broker access token is expired", async () => {
732+
await sodium.ready;
733+
734+
const testFileDir = path.dirname(fileURLToPath(import.meta.url));
735+
const repoRoot = path.dirname(testFileDir);
736+
const bridgePath = path.join(repoRoot, "slack-bridge", "broker-bridge.mjs");
737+
const bridgeCwd = path.join(repoRoot, "slack-bridge");
738+
739+
let bridgeStdout = "";
740+
let bridgeStderr = "";
741+
742+
const bridge = spawn("node", [bridgePath], {
743+
cwd: bridgeCwd,
744+
env: {
745+
...process.env,
746+
SLACK_BROKER_URL: "http://127.0.0.1:65535",
747+
SLACK_BROKER_WORKSPACE_ID: "T123BROKER",
748+
SLACK_BROKER_SERVER_PRIVATE_KEY: b64(32, 11),
749+
SLACK_BROKER_SERVER_PUBLIC_KEY: b64(32, 12),
750+
SLACK_BROKER_SERVER_SIGNING_PRIVATE_KEY: Buffer.alloc(32, 25).toString("base64"),
751+
SLACK_BROKER_PUBLIC_KEY: b64(32, 14),
752+
SLACK_BROKER_SIGNING_PUBLIC_KEY: b64(32, 15),
753+
SLACK_BROKER_ACCESS_TOKEN: "expired-token",
754+
SLACK_BROKER_ACCESS_TOKEN_EXPIRES_AT: "2000-01-01T00:00:00.000Z",
755+
SLACK_ALLOWED_USERS: "U_ALLOWED",
756+
BRIDGE_API_PORT: "0",
757+
},
758+
stdio: ["ignore", "pipe", "pipe"],
759+
});
760+
761+
bridge.stdout.on("data", (chunk) => {
762+
bridgeStdout += chunk.toString();
763+
});
764+
bridge.stderr.on("data", (chunk) => {
765+
bridgeStderr += chunk.toString();
766+
});
767+
768+
children.push(bridge);
769+
770+
const exited = await new Promise((resolve) => {
771+
bridge.on("exit", (code, signal) => resolve({ code, signal }));
772+
});
773+
774+
expect(exited.code).toBe(1);
775+
expect(`${bridgeStdout}\n${bridgeStderr}`).toContain("broker access token is expired");
776+
});
624777
});

0 commit comments

Comments
 (0)