feat(mosaic-as): agent registration + scoped/revocable tokens (US-007) (#541)
This commit was merged in pull request #541.
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
import {
|
||||
AgentTokenStore,
|
||||
AppserviceIntent,
|
||||
TransactionHandler,
|
||||
validateBridgeMessage,
|
||||
validateBridgeTyping,
|
||||
validateProvisionRoom,
|
||||
validateRegisterAgent,
|
||||
validateRevokeAgent,
|
||||
} from '@mosaicstack/appservice';
|
||||
import type { AppserviceConfig, MatrixEvent } from '@mosaicstack/appservice';
|
||||
|
||||
@@ -37,6 +40,13 @@ const safeEqual = (a: string, b: string): boolean => timingSafeEqual(digest(a),
|
||||
|
||||
const TXN_PATH = /^\/_matrix\/app\/v1\/transactions\/([^/]+)$/;
|
||||
|
||||
/**
|
||||
* Resolved identity for an authenticated /bridge/v1/* caller. Host principals
|
||||
* (the agent-comms host daemons) are unrestricted; agent principals are scoped
|
||||
* to a single virtual user and may only act as themselves.
|
||||
*/
|
||||
export type BridgePrincipal = { kind: 'host' } | { kind: 'agent'; agentUserId: string } | null;
|
||||
|
||||
/**
|
||||
* HTTP-framework-agnostic request router for the mosaic-as daemon: the
|
||||
* Application Service transactions endpoint (Synapse-facing) plus the
|
||||
@@ -46,6 +56,7 @@ const TXN_PATH = /^\/_matrix\/app\/v1\/transactions\/([^/]+)$/;
|
||||
export class AppserviceDaemon {
|
||||
readonly intent: AppserviceIntent;
|
||||
private readonly transactions: TransactionHandler;
|
||||
private readonly agents: AgentTokenStore;
|
||||
|
||||
constructor(
|
||||
private readonly cfg: DaemonConfig,
|
||||
@@ -53,6 +64,7 @@ export class AppserviceDaemon {
|
||||
private readonly log: (line: string) => void = (line) => console.log(line),
|
||||
) {
|
||||
this.intent = new AppserviceIntent(cfg, fetchImpl);
|
||||
this.agents = new AgentTokenStore(this.intent);
|
||||
this.transactions = new TransactionHandler({
|
||||
hsToken: cfg.hsToken,
|
||||
onEvent: (event) => this.onEvent(event),
|
||||
@@ -69,10 +81,20 @@ export class AppserviceDaemon {
|
||||
}
|
||||
}
|
||||
|
||||
private bridgeAuthorized(authorizationHeader: string | undefined): boolean {
|
||||
if (!authorizationHeader?.startsWith('Bearer ')) return false;
|
||||
/** Resolve the calling principal, or null when unauthorized. Fail-closed:
|
||||
* host tokens win (timing-safe compare); otherwise a magt_* bearer is looked
|
||||
* up in the agent token store; anything else is rejected. */
|
||||
private async bridgeAuthorized(
|
||||
authorizationHeader: string | undefined,
|
||||
): Promise<BridgePrincipal> {
|
||||
if (!authorizationHeader?.startsWith('Bearer ')) return null;
|
||||
const presented = authorizationHeader.slice('Bearer '.length);
|
||||
return this.cfg.bridgeTokens.some((token) => safeEqual(presented, token));
|
||||
if (this.cfg.bridgeTokens.some((token) => safeEqual(presented, token))) {
|
||||
return { kind: 'host' };
|
||||
}
|
||||
const agentUserId = await this.agents.verifyToken(presented);
|
||||
if (agentUserId) return { kind: 'agent', agentUserId };
|
||||
return null;
|
||||
}
|
||||
|
||||
async handle(req: DaemonRequest): Promise<DaemonResponse> {
|
||||
@@ -89,12 +111,60 @@ export class AppserviceDaemon {
|
||||
}
|
||||
|
||||
if (req.path.startsWith('/bridge/v1/')) {
|
||||
if (!this.bridgeAuthorized(req.authorizationHeader)) {
|
||||
const principal = await this.bridgeAuthorized(req.authorizationHeader);
|
||||
if (!principal) {
|
||||
return { status: 403, body: { errcode: 'M_FORBIDDEN', error: 'bad bridge token' } };
|
||||
}
|
||||
try {
|
||||
if (req.method === 'POST' && req.path === '/bridge/v1/agents') {
|
||||
if (principal.kind !== 'host') {
|
||||
return {
|
||||
status: 403,
|
||||
body: { errcode: 'M_FORBIDDEN', error: 'agents cannot register agents' },
|
||||
};
|
||||
}
|
||||
validateRegisterAgent(req.body);
|
||||
const { agentUserId, token } = await this.agents.register({
|
||||
alias: req.body.alias,
|
||||
host: req.body.host,
|
||||
displayName: req.body.display_name,
|
||||
});
|
||||
this.log(`registered agent ${agentUserId}`);
|
||||
return { status: 200, body: { agent_user_id: agentUserId, bridge_token: token } };
|
||||
}
|
||||
if (req.method === 'POST' && req.path === '/bridge/v1/agents/revoke') {
|
||||
if (principal.kind !== 'host') {
|
||||
return {
|
||||
status: 403,
|
||||
body: { errcode: 'M_FORBIDDEN', error: 'agents cannot revoke agents' },
|
||||
};
|
||||
}
|
||||
validateRevokeAgent(req.body);
|
||||
const revoked = await this.agents.revoke(req.body.agent_user_id);
|
||||
this.log(`revoked ${revoked} token(s) for ${req.body.agent_user_id}`);
|
||||
return { status: 200, body: { revoked } };
|
||||
}
|
||||
if (req.method === 'GET' && req.path === '/bridge/v1/agents') {
|
||||
if (principal.kind !== 'host') {
|
||||
return {
|
||||
status: 403,
|
||||
body: { errcode: 'M_FORBIDDEN', error: 'agents cannot list agents' },
|
||||
};
|
||||
}
|
||||
const agents = await this.agents.list();
|
||||
return { status: 200, body: { agents } };
|
||||
}
|
||||
if (req.method === 'POST' && req.path === '/bridge/v1/messages') {
|
||||
validateBridgeMessage(req.body);
|
||||
if (
|
||||
principal.kind === 'agent' &&
|
||||
this.intent.agentUserId(req.body.agent) !== principal.agentUserId
|
||||
) {
|
||||
return {
|
||||
status: 403,
|
||||
body: { errcode: 'M_FORBIDDEN', error: 'token not scoped to this agent' },
|
||||
};
|
||||
}
|
||||
const eventId = await this.intent.sendAsAgent({
|
||||
roomId: req.body.room_id,
|
||||
agent: req.body.agent,
|
||||
@@ -107,6 +177,15 @@ export class AppserviceDaemon {
|
||||
}
|
||||
if (req.method === 'POST' && req.path === '/bridge/v1/typing') {
|
||||
validateBridgeTyping(req.body);
|
||||
if (
|
||||
principal.kind === 'agent' &&
|
||||
this.intent.agentUserId(req.body.agent) !== principal.agentUserId
|
||||
) {
|
||||
return {
|
||||
status: 403,
|
||||
body: { errcode: 'M_FORBIDDEN', error: 'token not scoped to this agent' },
|
||||
};
|
||||
}
|
||||
await this.intent.setTyping(req.body.room_id, req.body.agent, req.body.typing);
|
||||
return { status: 200, body: {} };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user