From 7772d77167366d50886593b7ae8add02c3bd2f53 Mon Sep 17 00:00:00 2001
From: jbolor21 <86250273+jbolor21@users.noreply.github.com>
Date: Tue, 19 May 2026 14:35:28 -0700
Subject: [PATCH 1/8] adding files to add entra auth option to targets
---
.../Config/CreateTargetDialog.test.tsx | 148 +++++++++++++++
.../components/Config/CreateTargetDialog.tsx | 72 +++++++-
frontend/src/types/index.ts | 1 +
pyrit/backend/models/targets.py | 10 +-
pyrit/backend/services/target_service.py | 109 ++++++++++-
pyrit/prompt_target/azure_ml_chat_target.py | 85 +++++++--
tests/unit/backend/test_target_service.py | 171 ++++++++++++++++++
.../target/test_azure_ml_chat_target.py | 126 +++++++++++++
8 files changed, 693 insertions(+), 29 deletions(-)
diff --git a/frontend/src/components/Config/CreateTargetDialog.test.tsx b/frontend/src/components/Config/CreateTargetDialog.test.tsx
index b8a9dcfc0c..70966a9a9b 100644
--- a/frontend/src/components/Config/CreateTargetDialog.test.tsx
+++ b/frontend/src/components/Config/CreateTargetDialog.test.tsx
@@ -497,4 +497,152 @@ describe("CreateTargetDialog", () => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
+
+ it("should hide the API Key field and omit api_key/include auth_mode when Entra is selected", async () => {
+ const onCreated = jest.fn();
+ const user = userEvent.setup();
+ mockedTargetsApi.createTarget.mockResolvedValue({
+ target_registry_name: "openai_chat_entra",
+ target_type: "OpenAIChatTarget",
+ });
+
+ render(
+
+
+
+ );
+
+ await selectTargetType(user, "OpenAIChatTarget");
+
+ const endpointInput = screen.getByPlaceholderText(
+ "https://your-resource.openai.azure.com/"
+ );
+ fireEvent.change(endpointInput, {
+ target: { value: "https://my-resource.openai.azure.com/" },
+ });
+
+ // API Key field is visible by default.
+ expect(
+ screen.getByPlaceholderText("API key (stored in memory only)")
+ ).toBeInTheDocument();
+
+ // Select Entra radio.
+ await user.click(
+ screen.getByRole("radio", {
+ name: /Microsoft Entra ID/,
+ })
+ );
+
+ // API Key field is hidden in Entra mode.
+ expect(
+ screen.queryByPlaceholderText("API key (stored in memory only)")
+ ).not.toBeInTheDocument();
+
+ await user.click(screen.getByText("Create Target"));
+
+ await waitFor(() => {
+ expect(mockedTargetsApi.createTarget).toHaveBeenCalledWith({
+ type: "OpenAIChatTarget",
+ params: {
+ endpoint: "https://my-resource.openai.azure.com/",
+ },
+ auth_mode: "entra",
+ });
+ expect(onCreated).toHaveBeenCalled();
+ });
+ });
+
+ it("should clear a previously-typed API key when switching to Entra", async () => {
+ const onCreated = jest.fn();
+ const user = userEvent.setup();
+ mockedTargetsApi.createTarget.mockResolvedValue({
+ target_registry_name: "openai_chat_entra",
+ target_type: "OpenAIChatTarget",
+ });
+
+ render(
+
+
+
+ );
+
+ await selectTargetType(user, "OpenAIChatTarget");
+
+ const endpointInput = screen.getByPlaceholderText(
+ "https://your-resource.openai.azure.com/"
+ );
+ fireEvent.change(endpointInput, {
+ target: { value: "https://my-resource.openai.azure.com/" },
+ });
+
+ // Type a key, then switch to Entra.
+ fireEvent.change(
+ screen.getByPlaceholderText("API key (stored in memory only)"),
+ { target: { value: "sk-typed-before-switch" } }
+ );
+
+ await user.click(
+ screen.getByRole("radio", { name: /Microsoft Entra ID/ })
+ );
+
+ await user.click(screen.getByText("Create Target"));
+
+ await waitFor(() => {
+ const call = mockedTargetsApi.createTarget.mock.calls[0][0];
+ expect(call.auth_mode).toBe("entra");
+ expect(call.params).not.toHaveProperty("api_key");
+ });
+ });
+
+ it("should warn the user when Entra is selected for a non-Azure OpenAI endpoint", async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+
+ );
+
+ await selectTargetType(user, "OpenAIChatTarget");
+
+ const endpointInput = screen.getByPlaceholderText(
+ "https://your-resource.openai.azure.com/"
+ );
+ fireEvent.change(endpointInput, { target: { value: "https://api.openai.com/" } });
+
+ await user.click(
+ screen.getByRole("radio", { name: /Microsoft Entra ID/ })
+ );
+
+ expect(
+ screen.getByText(/Microsoft Entra ID authentication is only supported for Azure endpoints/)
+ ).toBeInTheDocument();
+ });
+
+ it("should NOT warn when Entra is selected for a recognized Azure endpoint", async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+
+ );
+
+ await selectTargetType(user, "OpenAIChatTarget");
+
+ const endpointInput = screen.getByPlaceholderText(
+ "https://your-resource.openai.azure.com/"
+ );
+ fireEvent.change(endpointInput, {
+ target: { value: "https://my-resource.openai.azure.com/" },
+ });
+
+ await user.click(
+ screen.getByRole("radio", { name: /Microsoft Entra ID/ })
+ );
+
+ expect(
+ screen.queryByText(/only supported for Azure endpoints/)
+ ).not.toBeInTheDocument();
+ });
});
diff --git a/frontend/src/components/Config/CreateTargetDialog.tsx b/frontend/src/components/Config/CreateTargetDialog.tsx
index 4bce08f563..c8b2870fa0 100644
--- a/frontend/src/components/Config/CreateTargetDialog.tsx
+++ b/frontend/src/components/Config/CreateTargetDialog.tsx
@@ -9,6 +9,8 @@ import {
Button,
Input,
Label,
+ Radio,
+ RadioGroup,
Select,
Switch,
Text,
@@ -32,6 +34,26 @@ const TARGET_TYPE_CONFIG: Record = {
const SUPPORTED_TARGET_TYPES = Object.keys(TARGET_TYPE_CONFIG)
+type AuthMode = 'api_key' | 'entra'
+
+// Mirrors the backend's strict hostname-suffix check (target_service.py).
+// Used to warn the user before they submit; the backend remains the source of truth.
+const AZURE_OPENAI_HOSTNAME_SUFFIXES = [
+ '.openai.azure.com',
+ '.ai.azure.com',
+ '.services.ai.azure.com',
+ '.cognitiveservices.azure.com',
+]
+
+function isAzureOpenAiEndpoint(endpoint: string): boolean {
+ try {
+ const host = new URL(endpoint).hostname.toLowerCase()
+ return AZURE_OPENAI_HOSTNAME_SUFFIXES.some((s) => host.endsWith(s))
+ } catch {
+ return false
+ }
+}
+
interface CreateTargetDialogProps {
open: boolean
onClose: () => void
@@ -45,6 +67,7 @@ export default function CreateTargetDialog({ open, onClose, onCreated }: CreateT
const [modelName, setModelName] = useState('')
const [hasDifferentUnderlying, setHasDifferentUnderlying] = useState(false)
const [underlyingModel, setUnderlyingModel] = useState('')
+ const [authMode, setAuthMode] = useState('api_key')
const [apiKey, setApiKey] = useState('')
const [maxNewTokens, setMaxNewTokens] = useState('400')
const [temperature, setTemperature] = useState('1.0')
@@ -54,7 +77,11 @@ export default function CreateTargetDialog({ open, onClose, onCreated }: CreateT
const [error, setError] = useState(null)
const [fieldErrors, setFieldErrors] = useState<{ targetType?: string; endpoint?: string }>({})
- const isAzureML = TARGET_TYPE_CONFIG[targetType] === 'azureml'
+ const targetKind = TARGET_TYPE_CONFIG[targetType]
+ const isAzureML = targetKind === 'azureml'
+ const isOpenAi = targetKind === 'openai'
+ const isEntra = authMode === 'entra'
+ const showNonAzureEntraWarning = isEntra && isOpenAi && endpoint !== '' && !isAzureOpenAiEndpoint(endpoint)
const resetForm = () => {
setTargetType('')
@@ -62,6 +89,7 @@ export default function CreateTargetDialog({ open, onClose, onCreated }: CreateT
setModelName('')
setHasDifferentUnderlying(false)
setUnderlyingModel('')
+ setAuthMode('api_key')
setApiKey('')
setMaxNewTokens('400')
setTemperature('1.0')
@@ -94,7 +122,7 @@ export default function CreateTargetDialog({ open, onClose, onCreated }: CreateT
endpoint,
}
if (modelName) params.model_name = modelName
- if (apiKey) params.api_key = apiKey
+ if (!isEntra && apiKey) params.api_key = apiKey
if (hasDifferentUnderlying && underlyingModel) params.underlying_model = underlyingModel
@@ -112,6 +140,7 @@ export default function CreateTargetDialog({ open, onClose, onCreated }: CreateT
await targetsApi.createTarget({
type: targetType,
params,
+ ...(isEntra ? { auth_mode: 'entra' as const } : {}),
})
resetForm()
onCreated()
@@ -243,15 +272,40 @@ export default function CreateTargetDialog({ open, onClose, onCreated }: CreateT
>
)}
-
- setApiKey(data.value)}
- />
+
+ {
+ const next = data.value as AuthMode
+ setAuthMode(next)
+ if (next === 'entra') setApiKey('')
+ }}
+ >
+
+
+
+ {showNonAzureEntraWarning && (
+
+
+ Microsoft Entra ID authentication is only supported for Azure endpoints
+ (*.openai.azure.com or *.ai.azure.com). This request will be rejected by the server.
+
+
+ )}
+
+ {!isEntra && (
+
+ setApiKey(data.value)}
+ />
+
+ )}
+
From 51ec04260b18927e4d30d091cdaafd3ba2e6064c Mon Sep 17 00:00:00 2001
From: jbolor21 <86250273+jbolor21@users.noreply.github.com>
Date: Wed, 20 May 2026 09:13:16 -0700
Subject: [PATCH 3/8] fixing UI for error dialog box, minor formatting
---
.../Config/CreateTargetDialog.styles.ts | 8 ++++++++
.../components/Config/CreateTargetDialog.tsx | 12 +++++------
pyrit/backend/services/target_service.py | 20 ++++++-------------
3 files changed, 20 insertions(+), 20 deletions(-)
diff --git a/frontend/src/components/Config/CreateTargetDialog.styles.ts b/frontend/src/components/Config/CreateTargetDialog.styles.ts
index 34f023786a..67fdb2aa2f 100644
--- a/frontend/src/components/Config/CreateTargetDialog.styles.ts
+++ b/frontend/src/components/Config/CreateTargetDialog.styles.ts
@@ -6,4 +6,12 @@ export const useCreateTargetDialogStyles = makeStyles({
flexDirection: 'column',
gap: tokens.spacingVerticalL,
},
+ warningMessage: {
+ width: '100%',
+ },
+ warningMessageBody: {
+ whiteSpace: 'normal',
+ overflowWrap: 'anywhere',
+ wordBreak: 'break-word',
+ },
})
diff --git a/frontend/src/components/Config/CreateTargetDialog.tsx b/frontend/src/components/Config/CreateTargetDialog.tsx
index 4324f353f9..c949c87e1a 100644
--- a/frontend/src/components/Config/CreateTargetDialog.tsx
+++ b/frontend/src/components/Config/CreateTargetDialog.tsx
@@ -36,8 +36,8 @@ const SUPPORTED_TARGET_TYPES = Object.keys(TARGET_TYPE_CONFIG)
type AuthMode = 'api_key' | 'entra'
-// Mirrors the backend's strict hostname-suffix check (target_service.py).
-// Used to warn the user before they submit; the backend remains the source of truth.
+// Mirrors backend's hostname-suffix check (list in target_service.py).
+// The backend still does the check and will reject unsupported endpoints, but this allows us to show a warning in the UI if the user selects Microsoft Entra authentication with a non-Azure OpenAI endpoint.
const AZURE_OPENAI_HOSTNAME_SUFFIXES = [
'.openai.azure.com',
'.ai.azure.com',
@@ -287,10 +287,10 @@ export default function CreateTargetDialog({ open, onClose, onCreated }: CreateT
{showNonAzureEntraWarning && (
-
-
- Microsoft Entra ID authentication is only supported for Azure endpoints
- (*.openai.azure.com or *.ai.azure.com). This request will be rejected by the server.
+
+
+ Error: Entra auth only works with Azure OpenAI / AI Foundry endpoints (for example,
+ *.openai.azure.com or *.ai.azure.com).
)}
diff --git a/pyrit/backend/services/target_service.py b/pyrit/backend/services/target_service.py
index c51ec9a611..ab645e06c4 100644
--- a/pyrit/backend/services/target_service.py
+++ b/pyrit/backend/services/target_service.py
@@ -204,11 +204,6 @@ async def create_target_async(self, *, request: CreateTargetRequest) -> TargetIn
Instantiates the target with the given type and params,
then registers it in the registry under its registry name.
- When ``request.auth_mode == "entra"``, an Azure token-provider callable is
- injected as ``api_key`` before instantiation. Any ``api_key`` value in the
- original ``params`` is discarded in that case. Entra ID is supported for
- OpenAI-family targets (against Azure endpoints) and ``AzureMLChatTarget``.
-
Args:
request: The create target request with type, params, and auth_mode.
@@ -216,13 +211,13 @@ async def create_target_async(self, *, request: CreateTargetRequest) -> TargetIn
TargetInstance with the new target's details.
Raises:
- ValueError: If the target type is not found, or if Entra ID is requested
+ ValueError: If the target type is not found, if Entra ID is requested
for an unsupported target type, or if Entra ID is requested for an
OpenAI target against a non-Azure endpoint.
"""
target_class = self._get_target_class(target_type=request.type)
- # Build a fresh params dict so we never mutate the incoming Pydantic model.
+ # Copy params so we can modify values (eg api_key) without changing request.params.
params: dict[str, Any] = dict(request.params)
if request.auth_mode == "entra":
@@ -237,15 +232,13 @@ async def create_target_async(self, *, request: CreateTargetRequest) -> TargetIn
@staticmethod
def _apply_entra_auth(*, target_class: type, target_type: str, params: dict[str, Any]) -> dict[str, Any]:
"""
- Replace any ``api_key`` in ``params`` with an Entra ID token provider for
+ Replace ``api_key`` in ``params`` with an Entra ID token provider for
the given target class.
Args:
- target_class (type): The target class being instantiated. Used to select the
- correct token-provider scope.
- target_type (str): The user-facing target type name (used only in error messages).
- params (dict[str, Any]): The target constructor parameters from the request.
- This dict is not mutated.
+ target_class (type): The target class being instantiated
+ target_type (str): The user-facing target type name
+ params (dict[str, Any]): The target constructor parameters from the request
Returns:
dict[str, Any]: A new params dict with ``api_key`` replaced by an async
@@ -257,7 +250,6 @@ def _apply_entra_auth(*, target_class: type, target_type: str, params: dict[str,
"""
new_params = dict(params)
if "api_key" in new_params:
- # User error: api_key isn't used in Entra mode. Don't log the value.
logger.debug("Discarding 'api_key' from params because auth_mode='entra'.")
new_params.pop("api_key", None)
From 91e891249844495067070876f44b57409f751d61 Mon Sep 17 00:00:00 2001
From: jbolor21 <86250273+jbolor21@users.noreply.github.com>
Date: Wed, 20 May 2026 11:10:21 -0700
Subject: [PATCH 4/8] cleanup/formatting changes
---
.../components/Config/CreateTargetDialog.test.tsx | 12 ++++++------
pyrit/backend/models/targets.py | 4 ++--
pyrit/backend/services/target_service.py | 9 +++------
3 files changed, 11 insertions(+), 14 deletions(-)
diff --git a/frontend/src/components/Config/CreateTargetDialog.test.tsx b/frontend/src/components/Config/CreateTargetDialog.test.tsx
index 70966a9a9b..743f229e88 100644
--- a/frontend/src/components/Config/CreateTargetDialog.test.tsx
+++ b/frontend/src/components/Config/CreateTargetDialog.test.tsx
@@ -529,7 +529,7 @@ describe("CreateTargetDialog", () => {
// Select Entra radio.
await user.click(
screen.getByRole("radio", {
- name: /Microsoft Entra ID/,
+ name: /Microsoft Entra Authentication/,
})
);
@@ -582,7 +582,7 @@ describe("CreateTargetDialog", () => {
);
await user.click(
- screen.getByRole("radio", { name: /Microsoft Entra ID/ })
+ screen.getByRole("radio", { name: /Microsoft Entra Authentication/ })
);
await user.click(screen.getByText("Create Target"));
@@ -611,11 +611,11 @@ describe("CreateTargetDialog", () => {
fireEvent.change(endpointInput, { target: { value: "https://api.openai.com/" } });
await user.click(
- screen.getByRole("radio", { name: /Microsoft Entra ID/ })
+ screen.getByRole("radio", { name: /Microsoft Entra Authentication/ })
);
expect(
- screen.getByText(/Microsoft Entra ID authentication is only supported for Azure endpoints/)
+ screen.getByText(/Entra auth only works with Azure OpenAI/)
).toBeInTheDocument();
});
@@ -638,11 +638,11 @@ describe("CreateTargetDialog", () => {
});
await user.click(
- screen.getByRole("radio", { name: /Microsoft Entra ID/ })
+ screen.getByRole("radio", { name: /Microsoft Entra Authentication/ })
);
expect(
- screen.queryByText(/only supported for Azure endpoints/)
+ screen.queryByText(/Entra auth only works with Azure OpenAI/)
).not.toBeInTheDocument();
});
});
diff --git a/pyrit/backend/models/targets.py b/pyrit/backend/models/targets.py
index 1bd319c117..925cc68ef0 100644
--- a/pyrit/backend/models/targets.py
+++ b/pyrit/backend/models/targets.py
@@ -83,8 +83,8 @@ class CreateTargetRequest(BaseModel):
auth_mode: Literal["api_key", "entra"] = Field(
"api_key",
description=(
- "Authentication mode. 'api_key' uses the api_key in params (default, backwards-compatible). "
- "'entra' uses Microsoft Entra ID (DefaultAzureCredential); requires an Azure endpoint and is "
+ "Authentication mode. 'api_key' uses the api_key in params (default)"
+ "'entra' uses Microsoft Entra ID; requires an Azure endpoint and is "
"supported by OpenAI-family targets and AzureMLChatTarget."
),
)
diff --git a/pyrit/backend/services/target_service.py b/pyrit/backend/services/target_service.py
index ab645e06c4..e8eb29724b 100644
--- a/pyrit/backend/services/target_service.py
+++ b/pyrit/backend/services/target_service.py
@@ -50,17 +50,14 @@
def _is_azure_openai_endpoint(endpoint: str) -> bool:
"""
Return True if ``endpoint`` resolves to a known Azure OpenAI / AI Foundry host.
-
- Strict hostname-suffix check (not a substring search) so a bearer token is
- never issued for an attacker-controlled endpoint whose URL merely contains
- the word "azure".
+ Uses a strict hostname-suffix check (not a substring search).
Args:
endpoint (str): The endpoint URL to validate.
Returns:
- bool: True if the endpoint's hostname ends with a recognised Azure OpenAI /
- AI Foundry suffix; False otherwise (including for malformed URLs).
+ bool: True if the endpoint's hostname ends with a recognised Azure suffix;
+ False otherwise
"""
hostname = (urlparse(endpoint).hostname or "").lower()
return any(hostname.endswith(suffix) for suffix in _AZURE_OPENAI_HOSTNAME_SUFFIXES)
From e086c5a91d7b794b539d15e7f0470a28f9425fa0 Mon Sep 17 00:00:00 2001
From: jbolor21 <86250273+jbolor21@users.noreply.github.com>
Date: Wed, 20 May 2026 11:12:49 -0700
Subject: [PATCH 5/8] clean up comments
---
.../src/components/Config/CreateTargetDialog.test.tsx | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/frontend/src/components/Config/CreateTargetDialog.test.tsx b/frontend/src/components/Config/CreateTargetDialog.test.tsx
index 743f229e88..fc5121298f 100644
--- a/frontend/src/components/Config/CreateTargetDialog.test.tsx
+++ b/frontend/src/components/Config/CreateTargetDialog.test.tsx
@@ -521,19 +521,19 @@ describe("CreateTargetDialog", () => {
target: { value: "https://my-resource.openai.azure.com/" },
});
- // API Key field is visible by default.
+ // check that API Key field is visible by default.
expect(
screen.getByPlaceholderText("API key (stored in memory only)")
).toBeInTheDocument();
- // Select Entra radio.
+ // Select Entra option.
await user.click(
screen.getByRole("radio", {
name: /Microsoft Entra Authentication/,
})
);
- // API Key field is hidden in Entra mode.
+ // check that API Key field is hidden when Entra mode is selected.
expect(
screen.queryByPlaceholderText("API key (stored in memory only)")
).not.toBeInTheDocument();
@@ -575,7 +575,7 @@ describe("CreateTargetDialog", () => {
target: { value: "https://my-resource.openai.azure.com/" },
});
- // Type a key, then switch to Entra.
+ // Type a key, then switch to Entra option.
fireEvent.change(
screen.getByPlaceholderText("API key (stored in memory only)"),
{ target: { value: "sk-typed-before-switch" } }
From 3e0b62dc19e0b9d8fd714d4f507cad538b33b53c Mon Sep 17 00:00:00 2001
From: jbolor21 <86250273+jbolor21@users.noreply.github.com>
Date: Wed, 20 May 2026 13:06:24 -0700
Subject: [PATCH 6/8] grey out create target if warning shown
---
.../Config/CreateTargetDialog.test.tsx | 28 +++++++++++++++
.../components/Config/CreateTargetDialog.tsx | 6 +++-
tests/unit/backend/test_target_service.py | 35 ++++++++-----------
3 files changed, 47 insertions(+), 22 deletions(-)
diff --git a/frontend/src/components/Config/CreateTargetDialog.test.tsx b/frontend/src/components/Config/CreateTargetDialog.test.tsx
index fc5121298f..cd096a192b 100644
--- a/frontend/src/components/Config/CreateTargetDialog.test.tsx
+++ b/frontend/src/components/Config/CreateTargetDialog.test.tsx
@@ -645,4 +645,32 @@ describe("CreateTargetDialog", () => {
screen.queryByText(/Entra auth only works with Azure OpenAI/)
).not.toBeInTheDocument();
});
+
+ it("should disable Create Target and skip API call for Entra + non-Azure OpenAI endpoint", async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+
+ );
+
+ await selectTargetType(user, "OpenAIChatTarget");
+
+ const endpointInput = screen.getByPlaceholderText(
+ "https://your-resource.openai.azure.com/"
+ );
+ fireEvent.change(endpointInput, { target: { value: "https://api.test.com/" } });
+
+ await user.click(
+ screen.getByRole("radio", { name: /Microsoft Entra Authentication/ })
+ );
+
+ const createButton = screen.getByText("Create Target").closest("button");
+ expect(createButton).toBeDisabled();
+
+ await user.click(screen.getByText("Create Target"));
+
+ expect(mockedTargetsApi.createTarget).not.toHaveBeenCalled();
+ });
});
diff --git a/frontend/src/components/Config/CreateTargetDialog.tsx b/frontend/src/components/Config/CreateTargetDialog.tsx
index c949c87e1a..94f433c17a 100644
--- a/frontend/src/components/Config/CreateTargetDialog.tsx
+++ b/frontend/src/components/Config/CreateTargetDialog.tsx
@@ -320,7 +320,11 @@ export default function CreateTargetDialog({ open, onClose, onCreated }: CreateT
-