Skip to content

Commit 472df87

Browse files
committed
Refactor OPA secrets management to use Token Exchange
1 parent bcc77e6 commit 472df87

File tree

5 files changed

+2319
-2072
lines changed

5 files changed

+2319
-2072
lines changed

packages/agent0/src/agent.ts

Lines changed: 58 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -231,46 +231,11 @@ export async function initializeLLMConfig(): Promise<void> {
231231
return;
232232
}
233233

234-
console.log('\n Initializing LLM configuration...');
234+
console.log('\n Initializing LLM configuration from environment variables...');
235235

236-
// Try OPA first if configured
237-
if (isOPAConfigured()) {
238-
console.log(' OPA secret management detected, attempting to fetch credentials...');
239-
try {
240-
const opaCredentials = await fetchLLMCredentialsFromOPA();
236+
// Note: OPA credentials are now fetched per-user-session in getAgentForUserContext()
237+
// This function only handles environment variable fallback
241238

242-
if (opaCredentials) {
243-
// Convert OPA credentials to AgentLLMConfig format
244-
if (opaCredentials.provider === 'anthropic') {
245-
llmConfig = {
246-
mcpServerUrl: process.env.MCP_SERVER_URL || '',
247-
llmProvider: 'anthropic',
248-
anthropicApiKey: opaCredentials.apiKey,
249-
anthropicModel: opaCredentials.model,
250-
};
251-
} else {
252-
llmConfig = {
253-
mcpServerUrl: process.env.MCP_SERVER_URL || '',
254-
llmProvider: 'bedrock',
255-
awsRegion: opaCredentials.awsRegion,
256-
awsAccessKeyId: opaCredentials.awsAccessKeyId,
257-
awsSecretAccessKey: opaCredentials.awsSecretAccessKey,
258-
awsSessionToken: opaCredentials.awsSessionToken,
259-
bedrockModelId: opaCredentials.bedrockModelId,
260-
};
261-
}
262-
llmConfigSource = 'opa';
263-
llmConfigInitialized = true;
264-
console.log(' LLM credentials loaded from OPA');
265-
return;
266-
}
267-
} catch (error: any) {
268-
console.warn(' Failed to fetch credentials from OPA:', error.message);
269-
console.warn(' Falling back to environment variables...');
270-
}
271-
}
272-
273-
// Fall back to environment variables
274239
try {
275240
llmConfig = validateAgentLLMEnv();
276241
llmConfigSource = 'env';
@@ -300,17 +265,20 @@ export function isLLMConfigInitialized(): boolean {
300265

301266
// Try synchronous initialization at module load (for backwards compatibility)
302267
// This will only work if env vars are set and OPA is not configured
268+
// Try synchronous initialization from env vars at module load
269+
// OPA credentials are fetched per-user-session, so we don't init them here
303270
try {
304271
if (!isOPAConfigured()) {
272+
// No OPA configured, must use env vars
305273
llmConfig = validateAgentLLMEnv();
306274
llmConfigSource = 'env';
307275
llmConfigInitialized = true;
308276
}
277+
// If OPA is configured, credentials will be fetched in getAgentForUserContext()
309278
} catch {
310-
// If sync init fails and OPA is configured, that's fine - will init async later
311-
// If sync init fails and OPA is not configured, we have a problem
279+
// If env var init fails and OPA is configured, that's fine - will use OPA per-session
312280
if (!isOPAConfigured()) {
313-
console.error('LLM configuration failed - no env vars or OPA configured');
281+
console.error('LLM configuration failed - no env vars configured and OPA not available');
314282
}
315283
}
316284

@@ -405,7 +373,55 @@ function buildAgentConfig(): Omit<AgentConfig, 'idToken' | 'userContext'> | null
405373
}
406374

407375
export async function getAgentForUserContext(idToken: string, userContext: UserContext): Promise<Agent> {
408-
// Ensure LLM config is initialized (will be a no-op if already initialized)
376+
// Try OPA token exchange for credential retrieval (per-user-session)
377+
if (isOPAConfigured()) {
378+
console.log('\n🔐 OPA secret management detected, fetching credentials via token exchange...');
379+
380+
try {
381+
const opaCredentials = await fetchLLMCredentialsFromOPA(idToken);
382+
383+
if (opaCredentials) {
384+
// Build config from OPA credentials
385+
const baseConfig = {
386+
mcpServerUrl: process.env.MCP_SERVER_URL || '',
387+
name: 'agent0',
388+
version: '1.0.0',
389+
tokenExchange: buildTokenExchangeConfig(),
390+
enableLLM: true,
391+
idToken,
392+
userContext,
393+
};
394+
395+
let agentConfig: AgentConfig;
396+
397+
if (opaCredentials.provider === 'anthropic') {
398+
agentConfig = {
399+
...baseConfig,
400+
anthropicApiKey: opaCredentials.apiKey,
401+
anthropicModel: opaCredentials.model,
402+
};
403+
} else {
404+
agentConfig = {
405+
...baseConfig,
406+
awsRegion: opaCredentials.awsRegion,
407+
awsAccessKeyId: opaCredentials.awsAccessKeyId,
408+
awsSecretAccessKey: opaCredentials.awsSecretAccessKey,
409+
awsSessionToken: opaCredentials.awsSessionToken,
410+
bedrockModelId: opaCredentials.bedrockModelId,
411+
};
412+
}
413+
414+
llmConfigSource = 'opa';
415+
console.log('✅ Agent configured with OPA credentials');
416+
return new Agent(agentConfig);
417+
}
418+
} catch (error: any) {
419+
console.warn('⚠️ Failed to fetch OPA credentials:', error.message);
420+
console.warn(' Falling back to environment variables...');
421+
}
422+
}
423+
424+
// Fallback to environment variables
409425
await initializeLLMConfig();
410426

411427
const agentConfig = buildAgentConfig();

packages/agent0/src/auth/token-exchange.ts

Lines changed: 80 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,6 @@ import { randomUUID } from 'crypto';
66
import jwt from 'jsonwebtoken';
77
import axios from 'axios';
88

9-
// Debug mode for verbose auth logging - OFF by default for security
10-
// Set AUTH_DEBUG=true to enable detailed logging (includes sensitive data)
11-
const AUTH_DEBUG = process.env.AUTH_DEBUG === 'true';
12-
139
// ============================================================================
1410
// Scope Challenge Types and Parser (MCP Authorization Best Practices)
1511
// ============================================================================
@@ -148,12 +144,6 @@ export class TokenExchangeHandler {
148144
formData.append('client_assertion_type', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
149145
formData.append('client_assertion', clientAssertion);
150146

151-
const requestedScopes = scopes || this.config.agentScopes;
152-
console.log(`🔄 Step 1: Exchanging ID token for ID-JAG token...`);
153-
console.log(`🎯 Requested scopes: ${requestedScopes}`);
154-
console.log(`📍 Audience: ${this.config.authorizationServer}`);
155-
console.log(`🆔 Client ID: ${this.config.clientId}`);
156-
157147
const response = await axios.post(
158148
`https://${this.config.oktaDomain}/oauth2/v1/token`,
159149
formData,
@@ -164,12 +154,89 @@ export class TokenExchangeHandler {
164154
}
165155
);
166156

167-
console.log(`✅ ID-JAG token obtained`);
168-
console.log(`🎯 Issued token type: ${response.data.issued_token_type}`);
169-
170157
return response.data.access_token; // This is actually the ID-JAG token
171158
}
172159

160+
// ============================================================================
161+
// Exchange ID Token for Vaulted Secret (PAM)
162+
// ============================================================================
163+
164+
/**
165+
* Exchange ID token for a vaulted secret from Okta PAM
166+
* @param idToken - The user's ID token
167+
* @param resourceOrn - The ORN of the secret (e.g., orn:okta:pam:{orgId}:secrets:{secretId})
168+
* @param secretName - Optional name for logging purposes
169+
* @returns The secret value
170+
*/
171+
async exchangeIdTokenForVaultedSecret(
172+
idToken: string,
173+
resourceOrn: string,
174+
secretName?: string
175+
): Promise<string> {
176+
if (!this.privateKey) {
177+
throw new Error('Private key not loaded for token exchange');
178+
}
179+
180+
const tokenEndpoint = `https://${this.config.oktaDomain}/oauth2/v1/token`;
181+
const clientAssertion = this.createClientAssertion(tokenEndpoint);
182+
183+
const formData = new URLSearchParams();
184+
formData.append('grant_type', 'urn:ietf:params:oauth:grant-type:token-exchange');
185+
formData.append('requested_token_type', 'urn:okta:params:oauth:token-type:vaulted-secret');
186+
formData.append('subject_token', idToken);
187+
formData.append('subject_token_type', 'urn:ietf:params:oauth:token-type:id_token');
188+
formData.append('resource', resourceOrn);
189+
formData.append('client_assertion_type', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
190+
formData.append('client_assertion', clientAssertion);
191+
192+
try {
193+
const response = await axios.post(tokenEndpoint, formData, {
194+
headers: {
195+
'Content-Type': 'application/x-www-form-urlencoded',
196+
},
197+
});
198+
199+
// Extract the actual secret value from the response
200+
const vaultedSecret = response.data.vaulted_secret;
201+
let secretValue: string;
202+
203+
if (typeof vaultedSecret === 'string') {
204+
secretValue = vaultedSecret;
205+
} else if (typeof vaultedSecret === 'object' && vaultedSecret !== null) {
206+
// Try common key names for secrets
207+
secretValue = vaultedSecret.secret
208+
|| vaultedSecret.password
209+
|| vaultedSecret.api_key
210+
|| vaultedSecret.apiKey
211+
|| vaultedSecret.token
212+
|| vaultedSecret.value
213+
|| Object.values(vaultedSecret)[0] as string;
214+
} else {
215+
throw new Error(`Invalid secret format received for ${secretName || resourceOrn}`);
216+
}
217+
218+
// Validate non-empty
219+
if (!secretValue || (typeof secretValue === 'string' && secretValue.trim() === '')) {
220+
throw new Error(`Empty secret value received for ${secretName || resourceOrn}`);
221+
}
222+
223+
return secretValue;
224+
} catch (error: any) {
225+
const errorData = error.response?.data;
226+
console.error(`❌ Failed to retrieve secret${secretName ? ` (${secretName})` : ''}:`, errorData || error.message);
227+
228+
if (errorData?.error === 'invalid_grant') {
229+
throw new Error(`Secret access denied: ${errorData.error_description || 'Invalid grant'}`);
230+
} else if (errorData?.error === 'invalid_target') {
231+
throw new Error(`Invalid secret resource: ${resourceOrn}`);
232+
}
233+
234+
throw new Error(
235+
`Failed to retrieve vaulted secret: ${errorData?.error_description || error.message}`
236+
);
237+
}
238+
}
239+
173240
// ============================================================================
174241
// Step 2: Exchange ID-JAG for Access Token
175242
// ============================================================================
@@ -183,9 +250,6 @@ export class TokenExchangeHandler {
183250
const authorizationServer = this.config.authorizationServer;
184251
const authorizationServerTokenEndpoint = this.config.authorizationServerTokenEndpoint;
185252

186-
console.log(`🔄 Step 2: Exchanging ID-JAG for Access Token at Resource Server...`);
187-
console.log(`📍 MCP authorization server: ${authorizationServer}`);
188-
189253
const clientAssertion = this.createClientAssertion(authorizationServerTokenEndpoint);
190254

191255
const resourceTokenForm = new URLSearchParams();
@@ -204,10 +268,6 @@ export class TokenExchangeHandler {
204268
}
205269
);
206270

207-
console.log(`✅ Access Token obtained from Resource Server`);
208-
console.log(`🎯 Token type: ${response.data.token_type}`);
209-
console.log(`⏰ Expires in: ${response.data.expires_in}s`);
210-
211271
return response.data;
212272
}
213273

@@ -235,14 +295,6 @@ export class TokenExchangeHandler {
235295
throw new Error('Cross-app access not configured properly. Private key not loaded.');
236296
}
237297

238-
// Only log tokens in debug mode - contains sensitive credentials
239-
if (AUTH_DEBUG) {
240-
console.log(`👻 Subject token: ${idToken}`);
241-
}
242-
if (requestedScopes) {
243-
console.log(`🔄 Step-up authorization requested with scopes: ${requestedScopes}`);
244-
}
245-
246298
try {
247299
// Step 1: Exchange ID token for ID-JAG
248300
const idJag = await this.exchangeIdTokenForIdJag(idToken, requestedScopes);
@@ -253,8 +305,6 @@ export class TokenExchangeHandler {
253305

254306
// Return the access token
255307
const accessToken = accessTokenResponse.access_token;
256-
console.log('✅ Access token obtained successfully');
257-
console.log('💡 Token can be used for MCP tool calls');
258308

259309
return {
260310
success: true,

0 commit comments

Comments
 (0)