Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"drizzle-orm": "^0.45.2",
"hono": "^4.12.23",
"hono-agents": "^3.0.7",
"jose": "^6.2.3",
"workers-ai-provider": "^3.1.8",
"zod": "^4.3.6"
},
Expand Down
14 changes: 14 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { jobRoutes } from './routes/jobs';
import { transactionRoutes } from './routes/transactions';
import { timelineRoutes } from './routes/timeline';
import { triageRoutes } from './routes/triage';
import { workspaceStudioRoutes } from './routes/workspace-studio';

// Re-export ActionAgent DO class so the runtime can find it
export { ActionAgent } from './agents/action-agent';
Expand Down Expand Up @@ -70,6 +71,13 @@ export type Env = {
PLAID_CLIENT_ID?: string;
PLAID_SECRET?: string;
PLAID_ENV?: string;
// ChittyRoux × Workspace Studio integration
// @canon: chittycanon://core/services/chittycommand/workspace-studio
CHITTYROUX_GCP_SA_EMAIL?: string;
CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_ID?: string;
CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_SECRET?: string;
REGISTERED_CHANNELS_JSON?: string;
GCP_JWKS_URL?: string;
};

const app = new Hono<{ Bindings: Env; Variables: AuthVariables }>();
Expand Down Expand Up @@ -167,6 +175,12 @@ app.route('/api/v1', timelineRoutes);
app.use('/agent/*', authMiddleware);
app.use('/agent/*', agentsMiddleware());

// Workspace Studio HTTP-mode add-on endpoints (Roux Ingest custom step).
// Auth is per-request via authorizationEventObject JWT verification — NOT
// the global /api/* authMiddleware. Mount outside /api/*.
// @canon: chittycanon://core/services/chittycommand/workspace-studio
app.route('/workspace/studio/roux-ingest', workspaceStudioRoutes);

// MCP server — authenticated via shared token in KV
app.use('/mcp/*', mcpAuthMiddleware);
app.route('/mcp', mcpRoutes);
Expand Down
68 changes: 68 additions & 0 deletions src/lib/channel-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Channel registry resolver.
*
* Per the global Channel Registration Protocol (CLAUDE.md), every channel/MCP
* client that talks to the ChittyOS ecosystem registers via
* `agent.chitty.cc/api/v1/channels/register` and receives back a ChittyID and
* capability manifest. This module is the chittycommand-side lookup.
*
* v1 (this file): env-var allowlist parsed from `REGISTERED_CHANNELS_JSON`
* (a JSON object keyed by channel_id).
*
* v2 (planned): HTTP call to `agent.chitty.cc/api/v1/channels/{channelId}`
* with the same return shape. See TODO below.
*
* @canon: chittycanon://core/services/chittycommand/channel-registry
*/

import type { Env } from '../index';

export interface ChannelMeta {
channel_id: string;
chitty_id: string;
platform: string;
capabilities: string[];
contact_endpoint?: string;
status: 'active' | 'suspended' | 'pending';
}

interface ChannelRegistryEnv extends Pick<Env, 'COMMAND_KV'> {
REGISTERED_CHANNELS_JSON?: string;
}

/**
* The canonical Workspace Studio channel ChittyID — used when registering the
* Workspace Add-on as a channel under the universal protocol. Surface this
* value so it can be added to REGISTERED_CHANNELS_JSON.
*/
export const WORKSPACE_STUDIO_CHANNEL_ID = 'chitty:channel:workspace-studio-gmail';

/**
* Resolve a channel's metadata, or null if the channel is not registered or
* not active.
*
* TODO(v2): Replace env-var lookup with a fetch to
* `agent.chitty.cc/api/v1/channels/{channelId}` and cache the result
* in `COMMAND_KV` under `channel:meta:{channel_id}` with a 5-minute
* TTL. Same return shape — callers do not need to change.
*/
export async function verifyRegisteredChannel(
channelId: string,
env: ChannelRegistryEnv,
): Promise<ChannelMeta | null> {
if (!channelId) return null;

const raw = env.REGISTERED_CHANNELS_JSON;
if (!raw) return null;
let parsed: Record<string, ChannelMeta>;
try {
parsed = JSON.parse(raw) as Record<string, ChannelMeta>;
} catch {
console.warn('[channel-registry] REGISTERED_CHANNELS_JSON is not valid JSON');
return null;
}
const meta = parsed[channelId];
if (!meta) return null;
if (meta.status !== 'active') return null;
return meta;
}
204 changes: 204 additions & 0 deletions src/lib/workspace-jwt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
/**
* Google Workspace Add-on (HTTP mode) ID token verification.
*
* Google sends two ID tokens in the `authorizationEventObject` payload of every
* Workspace Studio custom-step invocation:
*
* - `systemIdToken` — signed assertion that THIS request originated from
* Google's Apps Script / Workspace Add-on infrastructure. The `email`
* claim is the marketplace service account, the `aud` claim is the
* OAuth client ID configured for the add-on.
* - `userIdToken` — signed assertion identifying the END-USER acting in
* Workspace. Same audience pinning as `systemIdToken`.
*
* Both are RS256-signed by Google. Public keys live at the standard JWKS
* endpoint `https://www.googleapis.com/oauth2/v3/certs`.
*
* Docs:
* https://developers.google.com/workspace/add-ons/concepts/http-overview#verifying_jwts
*
* Design notes:
* - The JWKS URL is injectable via `env.GCP_JWKS_URL` so tests can point at
* a local static-served JWKS without globally mocking fetch.
* - JWKS responses are cached in `COMMAND_KV` under `gcp:jwks` for 3600s
* (Google rotates these keys daily; we re-fetch on cache miss / expiry).
*
* @canon: chittycanon://core/services/chittycommand/workspace-studio
*/

import { jwtVerify, importJWK, type JWK } from 'jose';
import type { Env } from '../index';

const DEFAULT_JWKS_URL = 'https://www.googleapis.com/oauth2/v3/certs';
const JWKS_KV_KEY = 'gcp:jwks';
const JWKS_TTL_SECONDS = 3600;

const ALLOWED_ISSUERS = new Set(['https://accounts.google.com', 'accounts.google.com']);

export class WorkspaceJWTError extends Error {
readonly code: string;
constructor(code: string, message: string) {
super(message);
this.code = code;
this.name = 'WorkspaceJWTError';
}
}

interface CachedJWKS {
keys: JWK[];
fetched_at: number;
}

interface WorkspaceEnv extends Pick<Env, 'COMMAND_KV'> {
CHITTYROUX_GCP_SA_EMAIL?: string;
CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_ID?: string;
GCP_JWKS_URL?: string;
}

async function getJWKS(env: WorkspaceEnv): Promise<JWK[]> {
const url = env.GCP_JWKS_URL ?? DEFAULT_JWKS_URL;

// KV cache first.
try {
const cached = await env.COMMAND_KV.get(JWKS_KV_KEY, { type: 'json' }) as CachedJWKS | null;
if (cached && Array.isArray(cached.keys) && cached.keys.length > 0) {
const age = Math.floor(Date.now() / 1000) - cached.fetched_at;
if (age < JWKS_TTL_SECONDS) return cached.keys;
}
} catch {
/* fall through to fetch */
}

const res = await fetch(url, { headers: { Accept: 'application/json' } });
if (!res.ok) {
throw new WorkspaceJWTError(
'JWKS_FETCH_FAILED',
`Failed to fetch JWKS from ${url}: HTTP ${res.status}`,
);
}
const body = await res.json() as { keys?: JWK[] };
if (!body.keys || !Array.isArray(body.keys) || body.keys.length === 0) {
throw new WorkspaceJWTError('JWKS_MALFORMED', 'JWKS response missing keys');
}
const payload: CachedJWKS = { keys: body.keys, fetched_at: Math.floor(Date.now() / 1000) };
try {
await env.COMMAND_KV.put(JWKS_KV_KEY, JSON.stringify(payload), { expirationTtl: JWKS_TTL_SECONDS });
} catch {
/* non-fatal — caching is opportunistic */
}
return body.keys;
}

async function verifyWithJWKS(
token: string,
jwks: JWK[],
expectedAudience: string,
): Promise<Record<string, unknown>> {
// jose has createRemoteJWKSet, but we cache through KV so we resolve the kid
// manually and importJWK for the matching entry.
const [headerB64] = token.split('.');
if (!headerB64) {
throw new WorkspaceJWTError('TOKEN_MALFORMED', 'JWT missing header segment');
}
let header: { kid?: string; alg?: string };
try {
const json = atob(headerB64.replace(/-/g, '+').replace(/_/g, '/'));
header = JSON.parse(json);
} catch {
throw new WorkspaceJWTError('TOKEN_MALFORMED', 'JWT header is not valid JSON');
}
const match = jwks.find((k) => k.kid === header.kid) ?? jwks[0];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refetch JWKS when the kid is absent

When Google starts signing tokens with a new kid while gcp:jwks still contains an older cached key set, this code falls back to jwks[0] and immediately verifies with the wrong key instead of treating the missing kid as a cache miss. Legitimate Workspace requests signed by the new key can be rejected for up to the one-hour KV TTL; require an exact kid match and refresh the JWKS before failing.

Useful? React with 👍 / 👎.

if (!match) {
throw new WorkspaceJWTError('JWKS_NO_MATCH', `No JWK matching kid=${header.kid}`);
}
const alg = header.alg || (match.alg as string) || 'RS256';
const key = await importJWK(match, alg);
try {
const { payload } = await jwtVerify(token, key, {
audience: expectedAudience,
algorithms: [alg],
});
return payload as Record<string, unknown>;
} catch (err) {
throw new WorkspaceJWTError(
'TOKEN_INVALID',
`JWT verification failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}

function requireIssuer(payload: Record<string, unknown>): void {
const iss = payload.iss;
if (typeof iss !== 'string' || !ALLOWED_ISSUERS.has(iss)) {
throw new WorkspaceJWTError(
'ISSUER_INVALID',
`Issuer ${iss} is not a recognized Google issuer`,
);
}
}

export interface WorkspaceTokenClaims {
sub: string;
email: string;
aud: string;
iss: string;
}

/**
* Verify Google's `systemIdToken`: the email claim MUST match the configured
* marketplace service account.
*/
export async function verifyWorkspaceSystemIdToken(
token: string,
env: WorkspaceEnv,
): Promise<WorkspaceTokenClaims> {
const expectedAud = env.CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_ID;
Comment thread
chitcommit marked this conversation as resolved.
Outdated
const expectedSa = env.CHITTYROUX_GCP_SA_EMAIL;
if (!expectedAud) throw new WorkspaceJWTError('CONFIG_MISSING', 'CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_ID not configured');
if (!expectedSa) throw new WorkspaceJWTError('CONFIG_MISSING', 'CHITTYROUX_GCP_SA_EMAIL not configured');

const jwks = await getJWKS(env);
const payload = await verifyWithJWKS(token, jwks, expectedAud);
requireIssuer(payload);

const email = typeof payload.email === 'string' ? payload.email : '';
if (email !== expectedSa) {
throw new WorkspaceJWTError(
'SA_MISMATCH',
`systemIdToken email ${email || '<missing>'} does not match expected SA`,
);
}
return {
sub: String(payload.sub ?? ''),
email,
aud: String(payload.aud ?? ''),
iss: String(payload.iss ?? ''),
};
}

/**
* Verify Google's `userIdToken`: identifies the end-user. No SA pinning — the
* email claim is the human user.
*/
export async function verifyWorkspaceUserIdToken(
token: string,
env: WorkspaceEnv,
): Promise<WorkspaceTokenClaims> {
const expectedAud = env.CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_ID;
if (!expectedAud) throw new WorkspaceJWTError('CONFIG_MISSING', 'CHITTYROUX_MARKETPLACE_OAUTH_CLIENT_ID not configured');

const jwks = await getJWKS(env);
const payload = await verifyWithJWKS(token, jwks, expectedAud);
requireIssuer(payload);

const email = typeof payload.email === 'string' ? payload.email : '';
if (!email) {
throw new WorkspaceJWTError('USER_EMAIL_MISSING', 'userIdToken missing email claim');
}
return {
sub: String(payload.sub ?? ''),
email,
aud: String(payload.aud ?? ''),
iss: String(payload.iss ?? ''),
};
}
Loading
Loading