Skip to content
Merged
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
42 changes: 42 additions & 0 deletions packages/api/src/auth/enforce-identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Identity Enforcement Utility
*
* Returns the effective agentId for WRITE operations, enforcing identity
* pinning when enabled. Read/query operations should NOT use this — they
* need to freely specify agentId as a filter parameter.
*
* Feature flag: ENFORCE_IDENTITY_PINNING (env var, default: 'true')
* 'true' — pinned identity overrides explicit agentId on writes
* 'false' — logs warnings but allows explicit agentId (warn-only mode)
*/

import { getPinnedAgentId } from '../utils/request-context';
import { env } from '../config/env';
import { logger } from '../utils/logger';

/**
* Returns the effective agentId for a write operation.
*
* - If identity is pinned (via bootstrap or token), returns the pinned value
* (or the explicit value if enforcement is disabled via feature flag).
* - If no identity is pinned (human user, pre-bootstrap), returns the explicit value.
*/
export function getEffectiveAgentId(explicitAgentId?: string): string | undefined {
const pinned = getPinnedAgentId();
if (!pinned) return explicitAgentId;

if (explicitAgentId && explicitAgentId !== pinned) {
const enforced = env.ENFORCE_IDENTITY_PINNING !== 'false';
logger.warn('Agent identity mismatch detected', {
claimed: explicitAgentId,
authenticated: pinned,
enforced,
});

if (!enforced) {
return explicitAgentId; // Feature flag off: warn but allow
}
}

return pinned;
}
23 changes: 21 additions & 2 deletions packages/api/src/auth/pcp-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export interface PcpTokenPayload {
sub: string; // PCP user ID
email: string;
scope: string;
agentId?: string; // Bound agent identity label (absent for human users)
identityId?: string; // Canonical agent_identities UUID (strongest binding)
}

// ============================================================================
Expand Down Expand Up @@ -81,7 +83,9 @@ export async function createRefreshToken(
userId: string,
clientId: string,
scopes: string[],
lifetimeDays: number
lifetimeDays: number,
agentId?: string,
identityId?: string
): Promise<{ refreshToken: string; expiresAt: Date }> {
const refreshToken = `pcp-rt-${crypto.randomBytes(32).toString('hex')}`;
const expiresAt = new Date(Date.now() + lifetimeDays * 24 * 60 * 60 * 1000);
Expand All @@ -93,6 +97,8 @@ export async function createRefreshToken(
supabase_refresh_token: null,
scopes,
expires_at: expiresAt.toISOString(),
...(agentId ? { agent_id: agentId } : {}),
...(identityId ? { identity_id: identityId } : {}),
});

if (error) {
Expand All @@ -115,7 +121,13 @@ export async function exchangeRefreshToken(
clientId: string,
tokenType: PcpTokenPayload['type'],
accessTokenLifetimeSeconds: number
): Promise<{ accessToken: string; userId: string; email: string } | null> {
): Promise<{
accessToken: string;
userId: string;
email: string;
agentId?: string;
identityId?: string;
} | null> {
const { data: tokenRecord, error: lookupError } = await supabase
.from('mcp_tokens')
.select('*, users(email)')
Expand Down Expand Up @@ -144,13 +156,18 @@ export async function exchangeRefreshToken(
const userEmail = (tokenRecord.users as unknown as { email: string | null })?.email || '';

const scope = tokenRecord.scopes?.join(' ') || 'mcp:tools';
const tokenAny = tokenRecord as Record<string, unknown>;
const agentId = tokenAny.agent_id as string | null;
const identityId = tokenAny.identity_id as string | null;

const accessToken = signPcpAccessToken(
{
type: tokenType,
sub: tokenRecord.user_id,
email: userEmail,
scope,
...(agentId ? { agentId } : {}),
...(identityId ? { identityId } : {}),
},
accessTokenLifetimeSeconds
);
Expand All @@ -165,5 +182,7 @@ export async function exchangeRefreshToken(
accessToken,
userId: tokenRecord.user_id,
email: userEmail,
...(agentId ? { agentId } : {}),
...(identityId ? { identityId } : {}),
};
}
3 changes: 3 additions & 0 deletions packages/api/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ const envSchema = z.object({
DISCORD_BOT_TOKEN: optionalString,
DISCORD_APPLICATION_ID: optionalString,

// Identity enforcement
ENFORCE_IDENTITY_PINNING: z.enum(['true', 'false']).default('true'),

// OAuth - Google
GOOGLE_CLIENT_ID: optionalString,
GOOGLE_CLIENT_SECRET: optionalString,
Expand Down
13 changes: 13 additions & 0 deletions packages/api/src/data/supabase/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1287,10 +1287,12 @@ export type Database = {
};
mcp_tokens: {
Row: {
agent_id: string | null;
client_id: string;
created_at: string | null;
expires_at: string;
id: string;
identity_id: string | null;
last_used_at: string | null;
refresh_token: string;
scopes: string[] | null;
Expand All @@ -1299,10 +1301,12 @@ export type Database = {
user_id: string;
};
Insert: {
agent_id?: string | null;
client_id: string;
created_at?: string | null;
expires_at: string;
id?: string;
identity_id?: string | null;
last_used_at?: string | null;
refresh_token: string;
scopes?: string[] | null;
Expand All @@ -1311,10 +1315,12 @@ export type Database = {
user_id: string;
};
Update: {
agent_id?: string | null;
client_id?: string;
created_at?: string | null;
expires_at?: string;
id?: string;
identity_id?: string | null;
last_used_at?: string | null;
refresh_token?: string;
scopes?: string[] | null;
Expand All @@ -1323,6 +1329,13 @@ export type Database = {
user_id?: string;
};
Relationships: [
{
foreignKeyName: 'mcp_tokens_identity_id_fkey';
columns: ['identity_id'];
isOneToOne: false;
referencedRelation: 'agent_identities';
referencedColumns: ['id'];
},
{
foreignKeyName: 'mcp_tokens_user_id_fkey';
columns: ['user_id'];
Expand Down
47 changes: 42 additions & 5 deletions packages/api/src/mcp/auth/pcp-auth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface PendingAuth {
codeChallenge: string;
redirectUri: string;
state: string;
agentId?: string;
expiresAt: number;
}

Expand All @@ -40,6 +41,7 @@ interface PendingAuthPayload {
codeChallenge: string;
redirectUri: string;
state: string;
agentId?: string;
}

export interface AuthCode {
Expand All @@ -48,6 +50,7 @@ export interface AuthCode {
redirectUri: string;
userId: string;
userEmail: string;
agentId?: string;
expiresAt: number;
}

Expand Down Expand Up @@ -105,13 +108,15 @@ export class PcpAuthProvider {
codeChallenge: string;
redirectUri: string;
state: string;
agentId?: string;
}): string {
const payload: PendingAuthPayload = {
type: 'pending_auth',
clientId: params.clientId,
codeChallenge: params.codeChallenge,
redirectUri: params.redirectUri,
state: params.state,
...(params.agentId ? { agentId: params.agentId } : {}),
};

return jwt.sign(payload, env.JWT_SECRET, {
Expand Down Expand Up @@ -214,6 +219,7 @@ export class PcpAuthProvider {
redirectUri: pending.redirectUri,
userId: pcpUser.id,
userEmail: pcpUser.email || '',
...(pending.agentId ? { agentId: pending.agentId } : {}),
expiresAt: Date.now() + AUTH_CODE_LIFETIME_MS,
});

Expand Down Expand Up @@ -279,7 +285,25 @@ export class PcpAuthProvider {
}
}

// Create refresh token in database
// Resolve canonical identity UUID when agent_id is provided
let identityId: string | undefined;
if (codeData.agentId) {
const { data: identity } = await this.supabase
.from('agent_identities')
.select('id')
.eq('user_id', codeData.userId)
.eq('agent_id', codeData.agentId)
.maybeSingle();
identityId = identity?.id;
if (!identityId) {
logger.warn('No agent_identities record found for token binding', {
userId: codeData.userId,
agentId: codeData.agentId,
});
}
}

// Create refresh token in database (with optional identity binding)
let refreshToken: string;
let expiresAt: Date;
try {
Expand All @@ -288,7 +312,9 @@ export class PcpAuthProvider {
codeData.userId,
clientId,
['mcp:tools'],
REFRESH_TOKEN_LIFETIME_DAYS
REFRESH_TOKEN_LIFETIME_DAYS,
codeData.agentId,
identityId
);
refreshToken = result.refreshToken;
expiresAt = result.expiresAt;
Expand All @@ -299,13 +325,15 @@ export class PcpAuthProvider {
// Consume the authorization code
this.authCodes.delete(params.code);

// Sign our own JWT as the access token
// Sign our own JWT as the access token (with optional identity binding)
const accessToken = signPcpAccessToken(
{
type: 'mcp_access',
sub: codeData.userId,
email: codeData.userEmail,
scope: 'mcp:tools',
...(codeData.agentId ? { agentId: codeData.agentId } : {}),
...(identityId ? { identityId } : {}),
},
ACCESS_TOKEN_LIFETIME_SECONDS
);
Expand All @@ -314,6 +342,8 @@ export class PcpAuthProvider {
userId: codeData.userId,
email: codeData.userEmail,
clientId,
agentId: codeData.agentId || 'none',
identityId: identityId || 'none',
refreshTokenExpires: expiresAt.toISOString(),
});

Expand Down Expand Up @@ -364,14 +394,21 @@ export class PcpAuthProvider {
// Token verification (for /mcp endpoint auth)
// --------------------------------------------------------------------------

verifyAccessToken(authHeader: string | undefined): { userId: string; email: string } | null {
verifyAccessToken(
authHeader: string | undefined
): { userId: string; email: string; agentId?: string; identityId?: string } | null {
if (!authHeader?.startsWith('Bearer ')) return null;
const token = authHeader.substring(7);

const payload = verifyPcpAccessToken(token, 'mcp_access');
if (!payload) return null;

return { userId: payload.sub, email: payload.email };
return {
userId: payload.sub,
email: payload.email,
...(payload.agentId ? { agentId: payload.agentId } : {}),
...(payload.identityId ? { identityId: payload.identityId } : {}),
};
}

// --------------------------------------------------------------------------
Expand Down
20 changes: 17 additions & 3 deletions packages/api/src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,14 @@ export class MCPServer {

// Use request-scoped context (AsyncLocalStorage) instead of global state
// to prevent identity leaking across concurrent stateless requests.
const ctx = userData ? { userId: userData.userId, email: userData.email } : {};
const ctx = userData
? {
userId: userData.userId,
email: userData.email,
agentId: userData.agentId,
identityId: userData.identityId,
}
: {};

await runWithRequestContext(ctx, async () => {
let transport: StreamableHTTPServerTransport | undefined;
Expand Down Expand Up @@ -324,9 +331,15 @@ export class MCPServer {

// Authorization endpoint — redirects to web portal for login
app.get('/authorize', (req, res) => {
const { client_id, redirect_uri, state, code_challenge, response_type } = req.query;
const { client_id, redirect_uri, state, code_challenge, response_type, agent_id } = req.query;

logger.info('MCP /authorize called', { client_id, redirect_uri, state, response_type });
logger.info('MCP /authorize called', {
client_id,
redirect_uri,
state,
response_type,
agent_id,
});

if (response_type !== 'code') {
res.status(400).json({ error: 'unsupported_response_type' });
Expand All @@ -338,6 +351,7 @@ export class MCPServer {
codeChallenge: code_challenge as string,
redirectUri: redirect_uri as string,
state: state as string,
agentId: agent_id as string | undefined,
});

const webPortalUrl = process.env.WEB_PORTAL_URL || 'http://localhost:3002';
Expand Down
5 changes: 3 additions & 2 deletions packages/api/src/mcp/tools/activity-stream-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { z } from 'zod';
import type { DataComposer } from '../../data/composer';
import { logger } from '../../utils/logger';
import { getEffectiveAgentId } from '../../auth/enforce-identity';
import { resolveUserOrThrow } from '../../services/user-resolver';
import type {
ActivityType,
Expand Down Expand Up @@ -165,7 +166,7 @@ export async function handleLogActivity(args: unknown, dataComposer: DataCompose

const activity = await dataComposer.repositories.activityStream.logActivity({
userId: user.id,
agentId: params.agentId,
agentId: getEffectiveAgentId(params.agentId) ?? params.agentId,
type: params.type as ActivityType,
content: params.content,
sessionId: params.sessionId,
Expand Down Expand Up @@ -222,7 +223,7 @@ export async function handleLogMessage(args: unknown, dataComposer: DataComposer

const activity = await dataComposer.repositories.activityStream.logMessage({
userId: user.id,
agentId: params.agentId,
agentId: getEffectiveAgentId(params.agentId) ?? params.agentId,
direction: params.direction,
content: params.content,
sessionId: params.sessionId,
Expand Down
Loading