Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
832 changes: 385 additions & 447 deletions packages/client-google-chat/package-lock.json

Large diffs are not rendered by default.

17 changes: 16 additions & 1 deletion packages/client-google-chat/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { Config, getConfigFromEnv } from './config/config.js';
import { Logger } from './utils/logger.js';
import { createStorageProvider, type StorageProvider } from './storage/index.js';
import { OIDCClient } from './services/oidcClient.js';
import { registerInstallations } from './services/installationRegistrar.js';
import { AwsSsmInstallationSecretService } from './services/awsSsmInstallationSecretService.js';
import { SSMClient } from '@aws-sdk/client-ssm';
import { UserAuthService } from './services/userAuthService.js';
import { A2AClientService } from './services/a2aClientService.js';
import { FileStorageService } from './services/fileStorageService.js';
Expand Down Expand Up @@ -148,6 +151,12 @@ function setupServerTimeouts(server: Server, config: Config) {
// OIDC client
const oidcClient = new OIDCClient(config);

// Per-installation notification secrets backed by AWS SSM Parameter Store.
const installationSecretService = new AwsSsmInstallationSecretService(
new SSMClient({ region: config.aws.region }),
config.installationSecret.ssmPrefix
);

// User auth service
const userAuthService = new UserAuthService(storage.userAuth, oidcClient, config, storage.oauthState);

Expand Down Expand Up @@ -425,7 +434,7 @@ function setupServerTimeouts(server: Server, config: Config) {

app.post(
'/api/v1/a2a/callback',
createA2ANotificationAuthMiddleware(config.googleChatConfigs),
createA2ANotificationAuthMiddleware(config.googleChatConfigs, installationSecretService),
async (req: Request, res: Response) => {
const task = req.body as Task;
const projectId = res.locals.projectNumber as string;
Expand Down Expand Up @@ -468,6 +477,12 @@ function setupServerTimeouts(server: Server, config: Config) {
});
setupServerTimeouts(server, config);

// Self-register each Google Chat project as a delivery channel with console-backend.
// Failures are isolated and never block startup.
registerInstallations({ config, oidcClient, installationSecretService }).catch((error) => {
logger.error(error, `Delivery-channel self-registration failed: ${error}`);
});

// -----------------------------------------------------------------------
// Task recovery
// -----------------------------------------------------------------------
Expand Down
15 changes: 11 additions & 4 deletions packages/client-google-chat/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ export interface Config {
readonly googleChatConfigs: {
projectName: string;
projectNumber: string; // GCP project number for verifying Google-signed tokens
botName: string; // Bot display name; used as the installation_id for delivery-channel registration
googleApplicationCredentials: any;
a2aNotificationSecret?: string; // Secret for validating A2A push notifications
}[];
readonly storage: StorageConfig;
readonly aws: {
Expand All @@ -41,6 +41,9 @@ export interface Config {
url: string;
audience: string;
};
readonly installationSecret: {
ssmPrefix: string;
};
}

export async function getConfigFromEnv(): Promise<Config> {
Expand Down Expand Up @@ -80,17 +83,16 @@ export async function getConfigFromEnv(): Promise<Config> {
}

const googleChatConfigs: Config['googleChatConfigs'] = [];
for (const project of JSON.parse(process.env.GCP_CHAT_PROJECTS) as { name: string; google_chat_app_id: string }[]) {
for (const project of JSON.parse(process.env.GCP_CHAT_PROJECTS) as { name: string; google_chat_app_id: string; bot_name: string }[]) {
const envVarName = `GCP_SA_JSON_KEY_${project.name.toUpperCase().replace(/-/g, '_')}`;
if (!process.env[envVarName]) {
throw new Error(`Please provide ${envVarName}`);
}
const secretEnvVar = `A2A_NOTIFICATION_SECRET_${project.name.toUpperCase().replace(/-/g, '_')}`;
googleChatConfigs.push({
projectName: project.name,
projectNumber: project.google_chat_app_id,
botName: project.bot_name,
googleApplicationCredentials: JSON.parse(process.env[envVarName]!),
a2aNotificationSecret: process.env[secretEnvVar],
});
}

Expand Down Expand Up @@ -158,5 +160,10 @@ export async function getConfigFromEnv(): Promise<Config> {
audience: process.env.OIDC_CONSOLE_BACKEND_AUDIENCE || 'agent-console',
}
: undefined,
installationSecret: {
ssmPrefix:
process.env.INSTALLATION_SECRET_SSM_PREFIX ||
`/nannos/${environment}/client-google-chat/installation-secrets`,
},
};
}
28 changes: 18 additions & 10 deletions packages/client-google-chat/src/middleware/a2aNotificationAuth.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,39 @@
import { Request, Response, NextFunction } from 'express';
import { Logger } from '../utils/logger.js';
import { Config } from '../config/config.js';
import { InstallationSecretService } from '../services/installationSecretService.js';

const logger = Logger.getLogger('A2ANotificationAuth');

/**
* Middleware to validate A2A push notification tokens.
*
* Matches the X-A2A-Notification-Token header against per-project secrets
* configured via A2A_NOTIFICATION_SECRET_<PROJECT_NAME> env vars.
* On success, sets res.locals.projectNumber
* Matches the X-A2A-Notification-Token header against the per-installation
* secret stored in AWS SSM Parameter Store (one secret per Google Chat
* bot, keyed by `botName`). On success, sets `res.locals.projectNumber`.
*/
export function createA2ANotificationAuthMiddleware(googleChatConfigs: Config['googleChatConfigs']) {
return (req: Request, res: Response, next: NextFunction): void => {
export function createA2ANotificationAuthMiddleware(
googleChatConfigs: Config['googleChatConfigs'],
installationSecretService: InstallationSecretService
) {
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const notificationToken = req.headers['x-a2a-notification-token'] as string | undefined;
if (!notificationToken) {
logger.warn('[A2ANotificationAuth] Missing X-A2A-Notification-Token header');
res.status(401).json({ error: 'Missing notification token' });
return;
}

// Resolve project by matching the secret
for (const project of googleChatConfigs) {
if (project.a2aNotificationSecret && project.a2aNotificationSecret === notificationToken) {
res.locals.projectNumber = project.projectNumber;
next();
return;
try {
const secret = await installationSecretService.get(project.botName);
if (secret && secret === notificationToken) {
res.locals.projectNumber = project.projectNumber;
next();
return;
}
} catch (err) {
logger.warn(`Failed to resolve secret for bot=${project.botName}: ${err}`);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* AwsSsmInstallationSecretService
* --------------------------------
* AWS SSM Parameter Store-backed implementation of InstallationSecretService.
* Stores secrets as SecureString parameters at `${prefix}/${sanitizedId}` and
* generates a 32-byte hex secret on first access.
*/

import { SSMClient, GetParameterCommand, PutParameterCommand } from '@aws-sdk/client-ssm';
import { randomBytes } from 'node:crypto';
import { InstallationSecretService } from './installationSecretService.js';
import { Logger } from '../utils/logger.js';

const logger = Logger.getLogger('AwsSsmInstallationSecretService');

export class AwsSsmInstallationSecretService extends InstallationSecretService {
constructor(
private readonly client: SSMClient,
private readonly prefix: string
) {
super();
}

protected async resolve(installationId: string): Promise<string> {
const existing = await this.read(installationId);
if (existing) return existing;

const name = this.parameterName(installationId);
const generated = randomBytes(32).toString('hex');
await this.client.send(
new PutParameterCommand({
Name: name,
Value: generated,
Type: 'SecureString',
Overwrite: false,
Description: `Notification secret for installation ${installationId}`,
})
);
logger.info(`Generated and stored notification secret in SSM for installation_id=${installationId}`);
return generated;
}

protected async read(installationId: string): Promise<string | null> {
const name = this.parameterName(installationId);
try {
const res = await this.client.send(new GetParameterCommand({ Name: name, WithDecryption: true }));
return res.Parameter?.Value ?? null;
} catch (err) {
if ((err as { name?: string })?.name === 'ParameterNotFound') return null;
throw err;
}
}

private parameterName(installationId: string): string {
const sanitized = installationId.replace(/[^a-zA-Z0-9_.\-/]/g, '_');
return `${this.prefix.replace(/\/$/, '')}/${sanitized}`;
}
}
100 changes: 100 additions & 0 deletions packages/client-google-chat/src/services/installationRegistrar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* InstallationRegistrar
* ---------------------
* Self-registers each tenant (Google Chat project) as a delivery channel with
* console-backend on startup. Idempotent: each registration is keyed by a
* deterministic `installation_id` so repeated boots never create duplicates.
*
* Authentication: server-to-server OAuth2 client_credentials grant against
* Keycloak;
*
* Failures are logged but never thrown — bot startup must not depend on
* console-backend availability.
*/

import { Config } from '../config/config.js';
import { OIDCClient } from './oidcClient.js';
import { InstallationSecretService } from './installationSecretService.js';
import { Logger } from '../utils/logger.js';

const logger = Logger.getLogger('InstallationRegistrar');

interface DeliveryChannelCreateBody {
name: string;
description?: string;
webhook_url: string;
secret: string;
group_ids: number[];
installation_id: string;
}

export interface InstallationRegistrarDeps {
config: Config;
oidcClient: OIDCClient;
installationSecretService: InstallationSecretService;
}

export async function registerInstallations(deps: InstallationRegistrarDeps): Promise<void> {
const { config } = deps;

if (!config.consoleBackend) {
logger.info('CONSOLE_BACKEND_URL not set — skipping delivery-channel self-registration');
return;
}

if (config.googleChatConfigs.length === 0) {
logger.info('No Google Chat projects configured — nothing to register');
return;
}

for (const project of config.googleChatConfigs) {
try {
await registerOne(deps, {
installationId: project.botName,
name: `Google Chat ${project.botName} (${project.projectName})`,
description: `Google Chat project ${project.projectName} via ${project.botName}`,
});
} catch (error) {
logger.error(error, `Failed to register delivery channel for project=${project.projectName}: ${error}`);
}
}
}

export async function registerOne(
deps: InstallationRegistrarDeps,
opts: { installationId: string; name: string; description?: string }
): Promise<void> {
const { config, oidcClient, installationSecretService } = deps;
if (!config.consoleBackend) return;

const secret = await installationSecretService.getOrCreate(opts.installationId);
const token = await oidcClient.getServiceToken(config.consoleBackend.audience);
const webhookUrl = new URL('/api/v1/a2a/callback', config.baseUrl).toString();

const body: DeliveryChannelCreateBody = {
name: opts.name,
description: opts.description,
webhook_url: webhookUrl,
secret,
group_ids: [],
installation_id: opts.installationId,
};

const url = new URL('/api/v1/delivery-channels', config.consoleBackend!.url).toString();
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});

if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`Console-backend returned ${response.status} ${response.statusText}: ${text}`);
}

const created = response.status === 201;
logger.info(`Delivery channel ${created ? 'created' : 'updated'} for installation_id=${opts.installationId}`);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* InstallationSecretService
* -------------------------
* Abstract per-installation notification-secret service. Concrete subclasses
* implement `resolve(installationId)` against a particular backend (AWS SSM,
* GCP Secret Manager, Vault, ...). The abstract base provides an in-memory
* Promise cache so callers can invoke `getOrCreate` repeatedly without
* re-hitting the backend.
*
* Implementations:
* - AwsSsmInstallationSecretService — see ./awsSsmInstallationSecretService.ts
*
* The same module is duplicated verbatim in client-slack — keep them in sync.
*/

export abstract class InstallationSecretService {
private readonly cache = new Map<string, string>();
private readonly inflightRead = new Map<string, Promise<string | null>>();

/**
* Resolve the notification secret for `installationId`, generating and
* persisting one if it does not yet exist. Successful results are cached
* for the process lifetime. Intended to be called serially at startup.
*/
async getOrCreate(installationId: string): Promise<string> {
const cached = this.cache.get(installationId);
if (cached) return cached;
const value = await this.resolve(installationId);
this.cache.set(installationId, value);
return value;
}

/**
* Read-only lookup. Returns the existing secret for `installationId`, or
* `null` if none has been provisioned. Successful reads are cached; misses
* are not, so a later registration becomes visible without restart.
* Concurrent callers share a single in-flight backend request.
*/
async get(installationId: string): Promise<string | null> {
const cached = this.cache.get(installationId);
if (cached) return cached;

let pending = this.inflightRead.get(installationId);
if (!pending) {
pending = (async () => {
try {
const value = await this.read(installationId);
if (value !== null) this.cache.set(installationId, value);
return value;
} finally {
this.inflightRead.delete(installationId);
}
})();
this.inflightRead.set(installationId, pending);
}
return pending;
}

/** Backend-specific get-or-create: read if present, otherwise generate, persist, and return. */
protected abstract resolve(installationId: string): Promise<string>;

/** Backend-specific read-only lookup; returns `null` when no secret exists. */
protected abstract read(installationId: string): Promise<string | null>;
}

15 changes: 15 additions & 0 deletions packages/client-google-chat/src/services/oidcClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,21 @@ export class OIDCClient {
}
}

/**
* Acquire a server-to-server access token via the OAuth2 client_credentials grant.
*/
async getServiceToken(audience: string): Promise<string> {
const config = await this.getConfiguration();
this.logger.info(`Requesting client_credentials token for audience=${audience}`);

const response = await client.clientCredentialsGrant(config, {
audience,
scope: 'openid',
});

return response.access_token;
}

/**
* Map openid-client token set to UserAuthToken
*/
Expand Down
Loading