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
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,18 @@ public void pushFrameworkEvent(
agentService.pushFrameworkEvent(executionId, event);
}

/** List all registered agents. */
/**
* List agents. When {@code endpoint} is provided, returns agents discovered from that Microsoft
* Foundry project URL instead of the local registry. {@code credentialRef} names the secret
* holding auth credentials (API key or Service Principal JSON); omit to use ambient identity.
*/
@GetMapping("/list")
public List<AgentSummary> listAgents() {
public List<AgentSummary> listAgents(
@RequestParam(name = "endpoint", required = false) String endpoint,
@RequestParam(name = "credentialRef", required = false) String credentialRef) {
if (endpoint != null && !endpoint.isBlank()) {
return agentService.listExternalAgents(credentialRef, endpoint);
}
return agentService.listAgents();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/*
* Copyright 2026 Conductor Authors.
* <p>
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/
package org.conductoross.conductor.ai.agentspan.runtime.controller;

import java.util.List;

import org.conductoross.conductor.ai.agentspan.runtime.service.OAuthTokenService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import lombok.RequiredArgsConstructor;

/**
* Endpoints for the delegated-access OAuth 2.0 flow.
*
* <ul>
* <li>{@code GET /api/oauth/authorize} — returns the provider authorization URL for the UI to
* open as a popup
* <li>{@code GET /api/oauth/callback} — receives the authorization code from the provider,
* exchanges it for a refresh token, stores it as a secret, and closes the popup
* </ul>
*/
@RestController
@RequestMapping("/api/oauth")
@RequiredArgsConstructor
@ConditionalOnProperty(name = "conductor.integrations.ai.enabled", havingValue = "true")
public class OAuthController {

private static final Logger log = LoggerFactory.getLogger(OAuthController.class);

private final OAuthTokenService oAuthTokenService;

/**
* Returns the Microsoft authorization URL the UI should open as a popup.
*
* @param key the {@code key} field from the workflow's {@code requiredDelegations} entry
* @param secretRef the secret name where the refresh token will be stored
* @param scopes space-separated OAuth scopes (e.g. {@code "https://ai.azure.com/.default offline_access"})
*/
@GetMapping("/authorize")
public ResponseEntity<String> authorize(
@RequestParam("key") String key,
@RequestParam("secretRef") String secretRef,
@RequestParam("scopes") String scopes) {

List<String> scopeList = List.of(scopes.split("\\s+"));
String url = oAuthTokenService.buildAuthorizationUrl(key, secretRef, scopeList);
return ResponseEntity.ok(url);
}

/**
* OAuth callback — Microsoft redirects here after the user consents. Exchanges the code for a
* refresh token, stores it, and serves a small HTML page that notifies the opener popup and
* closes itself.
*/
@GetMapping(value = "/callback", produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> callback(
@RequestParam(value = "code", required = false) String code,
@RequestParam(value = "state", required = false) String state,
@RequestParam(value = "error", required = false) String error,
@RequestParam(value = "error_description", required = false) String errorDescription) {

if (error != null) {
log.warn("OAuth callback received error: {} — {}", error, errorDescription);
return ResponseEntity.ok(closePopupHtml(false, null, error));
}

try {
String decoded = oAuthTokenService.handleCallback(code, state);
String key = decoded.split(":", 2)[0];
return ResponseEntity.ok(closePopupHtml(true, key, null));
} catch (Exception e) {
log.error("OAuth callback failed", e);
return ResponseEntity.ok(closePopupHtml(false, null, e.getMessage()));
}
}

/**
* Serves a minimal HTML page that posts a message to the parent window and closes the popup.
* The UI listens for {@code window.addEventListener('message', ...)} to detect completion.
*/
private String closePopupHtml(boolean success, String key, String errorMsg) {
String payload = success
? "{\"type\":\"oauth-complete\",\"success\":true,\"key\":\"" + key + "\"}"
: "{\"type\":\"oauth-complete\",\"success\":false,\"error\":\"" + escapeJson(errorMsg) + "\"}";

return "<!DOCTYPE html><html><body><script>"
+ "try { window.opener.postMessage(" + payload + ", '*'); } catch(e) {}"
+ "window.close();"
+ "</script><p>"
+ (success ? "Authorization complete. You may close this window." : "Authorization failed: " + escapeHtml(errorMsg))
+ "</p></body></html>";
}

private static String escapeJson(String s) {
return s == null ? "" : s.replace("\\", "\\\\").replace("\"", "\\\"");
}

private static String escapeHtml(String s) {
return s == null ? "" : s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,26 @@ private Map<String, Object> storedAgentDef(WorkflowDef def) {

// ── Agent discovery ─────────────────────────────────────────────

/**
* List agents from a specific Microsoft Foundry project URL. Used by the UI agent picker to
* populate the agent dropdown when the user enters a project endpoint.
*
* @param credentialRef secret name holding auth creds (API key or SP JSON); null = ambient identity
* @param endpoint Foundry project URL, e.g. {@code https://x.services.ai.azure.com/api/projects/y}
*/
public List<AgentSummary> listExternalAgents(String credentialRef, String endpoint) {
if (azureFoundryAgentClient == null) {
log.warn("Microsoft Foundry agent client not available");
return List.of();
}
try {
return azureFoundryAgentClient.listExternalAgents(credentialRef, endpoint);
} catch (Exception e) {
log.warn("Failed to list agents from endpoint '{}': {}", endpoint, e.getMessage());
return List.of();
}
}

/** List all registered agents (workflow defs with agent_sdk metadata). */
public List<AgentSummary> listAgents() {
// Use the portable getAllWorkflowDefs() (present across Conductor cores, incl. orkes'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -435,23 +435,44 @@
}

// Auth detection order:
// 0. useCallerIdentity + callerEntraToken + SP creds → OBO bearer (enterprise only)
// 1. apiKey in secret → API key header (no SDK)
// 2. client_id/secret/tenant → ClientSecretCredential (Service Principal)
// 3. clientId only → ManagedIdentityCredential (user-assigned)
// 4. no credentialRef or empty → DefaultAzureCredential (env vars → MI → CLI)
// 0. accessToken set directly → use as bearer (delegated/offline flow)
// 1. useCallerIdentity + callerEntraToken + SP creds → OBO bearer (enterprise only)
// Resolves a single field from credentialRef, which may be either:
// a) a secret name ("shailesh-ai") → delegates to credentialResolutionService
// b) an already-resolved JSON blob ({"apiKey":"..."}) → parses directly
// Case (b) occurs when the task stores ${workflow.secrets.X} and Conductor substitutes the
// secret value before the worker receives the request.
private String resolveCredField(String credentialRef, String field) {
if (credentialRef != null && credentialRef.startsWith("{")) {
try {
return MAPPER.readTree(credentialRef).path(field).asText(null);
} catch (Exception e) {
log.debug("credentialRef looks like JSON but could not be parsed: {}", e.getMessage());
}
}
return credentialResolutionService.resolve(credentialRef + "." + field);
}

// 2. apiKey in secret → API key header (no SDK)
// 3. client_id/secret/tenant → ClientSecretCredential (Service Principal)
// 4. clientId only → ManagedIdentityCredential (user-assigned)
// 5. no credentialRef or empty → DefaultAzureCredential (env vars → MI → CLI)
AuthState buildAuthState(ConductorAgentStartRequest request, String endpoint) {
String scope = resolveScope(request, endpoint);
String credentialRef = request.getCredentialRef();

if (StringUtils.isNotBlank(request.getAccessToken())) {

Check failure on line 464 in agentspan/src/main/java/org/conductoross/conductor/ai/agentspan/runtime/service/AzureFoundryAgentClient.java

View workflow job for this annotation

GitHub Actions / test-harness

cannot find symbol

Check failure on line 464 in agentspan/src/main/java/org/conductoross/conductor/ai/agentspan/runtime/service/AzureFoundryAgentClient.java

View workflow job for this annotation

GitHub Actions / build

cannot find symbol

Check failure on line 464 in agentspan/src/main/java/org/conductoross/conductor/ai/agentspan/runtime/service/AzureFoundryAgentClient.java

View workflow job for this annotation

GitHub Actions / build

cannot find symbol

Check failure on line 464 in agentspan/src/main/java/org/conductoross/conductor/ai/agentspan/runtime/service/AzureFoundryAgentClient.java

View workflow job for this annotation

GitHub Actions / unit-test

cannot find symbol
log.debug("Using pre-obtained access_token for delegated access");
return AuthState.ofBearer(request.getAccessToken());

Check failure on line 466 in agentspan/src/main/java/org/conductoross/conductor/ai/agentspan/runtime/service/AzureFoundryAgentClient.java

View workflow job for this annotation

GitHub Actions / test-harness

cannot find symbol

Check failure on line 466 in agentspan/src/main/java/org/conductoross/conductor/ai/agentspan/runtime/service/AzureFoundryAgentClient.java

View workflow job for this annotation

GitHub Actions / build

cannot find symbol

Check failure on line 466 in agentspan/src/main/java/org/conductoross/conductor/ai/agentspan/runtime/service/AzureFoundryAgentClient.java

View workflow job for this annotation

GitHub Actions / build

cannot find symbol

Check failure on line 466 in agentspan/src/main/java/org/conductoross/conductor/ai/agentspan/runtime/service/AzureFoundryAgentClient.java

View workflow job for this annotation

GitHub Actions / unit-test

cannot find symbol
}

// OBO: exchange caller's Entra SSO token for a Foundry-scoped token.
// Only activates when all three are present: flag, user assertion, and SP credentials.
if (request.isUseCallerIdentity() && StringUtils.isNotBlank(request.getUserAssertion())) {
if (StringUtils.isNotBlank(credentialRef)) {
String tenantId = credentialResolutionService.resolve(credentialRef + ".tenant_id");
String clientId = credentialResolutionService.resolve(credentialRef + ".client_id");
String clientSecret =
credentialResolutionService.resolve(credentialRef + ".client_secret");
String tenantId = resolveCredField(credentialRef, "tenant_id");
String clientId = resolveCredField(credentialRef, "client_id");
String clientSecret = resolveCredField(credentialRef, "client_secret");
if (StringUtils.isNoneBlank(tenantId, clientId, clientSecret)) {
String foundryToken =
exchangeOboToken(
Expand All @@ -470,16 +491,15 @@

if (StringUtils.isNotBlank(credentialRef)) {
// API key
String apiKey = credentialResolutionService.resolve(credentialRef + ".apiKey");
String apiKey = resolveCredField(credentialRef, "apiKey");
if (StringUtils.isNotBlank(apiKey)) {
return new AuthState(apiKey);
}

// Service Principal (client credentials)
String clientId = credentialResolutionService.resolve(credentialRef + ".client_id");
String clientSecret =
credentialResolutionService.resolve(credentialRef + ".client_secret");
String tenantId = credentialResolutionService.resolve(credentialRef + ".tenant_id");
String clientId = resolveCredField(credentialRef, "client_id");
String clientSecret = resolveCredField(credentialRef, "client_secret");
String tenantId = resolveCredField(credentialRef, "tenant_id");
if (StringUtils.isNoneBlank(clientId, clientSecret, tenantId)) {
TokenCredential cred =
new ClientSecretCredentialBuilder()
Expand All @@ -491,7 +511,7 @@
}

// User-assigned managed identity
String miClientId = credentialResolutionService.resolve(credentialRef + ".clientId");
String miClientId = resolveCredField(credentialRef, "clientId");
if (StringUtils.isNotBlank(miClientId)) {
TokenCredential cred =
new ManagedIdentityCredentialBuilder().clientId(miClientId).build();
Expand All @@ -509,8 +529,7 @@
StringUtils.defaultIfBlank(
rawConfig(request, "scope"),
StringUtils.isNotBlank(request.getCredentialRef())
? credentialResolutionService.resolve(
request.getCredentialRef() + ".scope")
? resolveCredField(request.getCredentialRef(), "scope")
: null);
if (StringUtils.isNotBlank(scope)) return scope;

Expand Down Expand Up @@ -801,12 +820,14 @@
AgentSummary.builder()
.name(name)
.version(1)
.type("azure-foundry")
.type("microsoft-foundry")
.endpoint(endpoint)
.credentialRef(credentialRef)
.description(description)
.createTime(createdAt)
.build());
}
log.debug("Discovered {} Azure agents from {}", result.size(), endpoint);
log.debug("Discovered {} Microsoft Foundry agents from {}", result.size(), endpoint);
return result;
} catch (Exception e) {
log.warn("Failed to list Azure agents from {}: {}", endpoint, e.getMessage());
Expand Down
Loading
Loading