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
12 changes: 11 additions & 1 deletion doc/code/setup/1_configuration.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,17 @@
")\n",
"```\n",
"\n",
"`auth_mode` applies to the OpenAI targets, `AzureMLChatTarget`, and `PromptShieldTarget`. `AzureBlobStorageTarget` accepts the same `auth_mode=\"identity\"` to bypass its SAS token sources; when no SAS token is configured it still selects a credential automatically, and that fallback is deprecated on the same schedule.\n",
"Pass `auth_mode=\"none\"` for an OpenAI-compatible server that needs no key, such as a local Ollama or vLLM server. The target then reads no key (not even the API key environment variable or `OPENAI_ADDITIONAL_REQUEST_HEADERS`) and mints no token. The OpenAI SDK still requires a key, so the target sends the fixed placeholder `not-needed`, which such servers ignore. It raises a `ValueError` if you also pass an explicit `api_key`.\n",
"\n",
"```python\n",
"target = OpenAIChatTarget(\n",
" endpoint=\"http://127.0.0.1:11434/v1\",\n",
" model_name=\"llama3\",\n",
" auth_mode=\"none\",\n",
")\n",
"```\n",
"\n",
"`auth_mode` applies to the OpenAI targets, `AzureMLChatTarget`, and `PromptShieldTarget`; `auth_mode=\"none\"` is for the OpenAI targets only. `AzureBlobStorageTarget` accepts the same `auth_mode=\"identity\"` to bypass its SAS token sources; when no SAS token is configured it still selects a credential automatically, and that fallback is deprecated on the same schedule.\n",
"\n",
"> **Migration note.** Earlier versions silently minted an Entra token when no key was found and the endpoint looked like an Azure host. That fallback still works, but it is deprecated and removed in 1.4.0 because it made an explicit `auth_mode=\"identity\"` indistinguishable from \"no key configured\". Pass `auth_mode=\"identity\"` instead (or a token provider as `api_key`, as the examples above do)."
]
Expand Down
12 changes: 11 additions & 1 deletion doc/code/setup/1_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,17 @@
# )
# ```
#
# `auth_mode` applies to the OpenAI targets, `AzureMLChatTarget`, and `PromptShieldTarget`. `AzureBlobStorageTarget` accepts the same `auth_mode="identity"` to bypass its SAS token sources; when no SAS token is configured it still selects a credential automatically, and that fallback is deprecated on the same schedule.
# Pass `auth_mode="none"` for an OpenAI-compatible server that needs no key, such as a local Ollama or vLLM server. The target then reads no key (not even the API key environment variable or `OPENAI_ADDITIONAL_REQUEST_HEADERS`) and mints no token. The OpenAI SDK still requires a key, so the target sends the fixed placeholder `not-needed`, which such servers ignore. It raises a `ValueError` if you also pass an explicit `api_key`.
#
# ```python
# target = OpenAIChatTarget(
# endpoint="http://127.0.0.1:11434/v1",
# model_name="llama3",
# auth_mode="none",
# )
# ```
#
# `auth_mode` applies to the OpenAI targets, `AzureMLChatTarget`, and `PromptShieldTarget`; `auth_mode="none"` is for the OpenAI targets only. `AzureBlobStorageTarget` accepts the same `auth_mode="identity"` to bypass its SAS token sources; when no SAS token is configured it still selects a credential automatically, and that fallback is deprecated on the same schedule.
#
# > **Migration note.** Earlier versions silently minted an Entra token when no key was found and the endpoint looked like an Azure host. That fallback still works, but it is deprecated and removed in 1.4.0 because it made an explicit `auth_mode="identity"` indistinguishable from "no key configured". Pass `auth_mode="identity"` instead (or a token provider as `api_key`, as the examples above do).

Expand Down
87 changes: 86 additions & 1 deletion frontend/src/components/Config/CreateTargetDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ const TARGET_TYPES: TargetTypeListResponse = {
},
...OPENAI_COMMON_PARAMETERS,
],
supported_auth_modes: ["api_key", "identity"],
supported_auth_modes: ["api_key", "identity", "none"],
description: "Facilitates multimodal (image and text) input and text output generation.",
},
{
Expand Down Expand Up @@ -1878,6 +1878,91 @@ describe("CreateTargetDialog", () => {
});
});

it("should submit no-key auth without an API key", async () => {
const onCreated = jest.fn();
const user = userEvent.setup();
mockedTargetsApi.createTarget.mockResolvedValue(makeTarget({
target_registry_name: "local_chat",
target_type: "OpenAIChatTarget",
}));

render(
<TestWrapper>
<CreateTargetDialog {...defaultProps} onCreated={onCreated} />
</TestWrapper>
);

await selectTargetType("OpenAIChatTarget");

expect(screen.getByRole("region", { name: /selected target details/i })).toHaveTextContent(
"Supported authentication: API key or Microsoft Entra ID or No key",
);
await user.type(
screen.getByPlaceholderText("https://your-resource.openai.azure.com/"),
"http://localhost:11434/v1",
);
await user.type(screen.getByPlaceholderText("API key (stored in memory only)"), "sk-typed-before-switch");
await user.click(screen.getByRole("radio", { name: /No key/ }));

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: "http://localhost:11434/v1",
},
auth_mode: "none",
});
expect(onCreated).toHaveBeenCalled();
});
});

it("should offer no-key auth only for target types that support it", async () => {
render(
<TestWrapper>
<CreateTargetDialog {...defaultProps} />
</TestWrapper>
);

await selectTargetType("AzureMLChatTarget");

expect(screen.getByRole("radio", { name: /Identity-based/ })).toBeInTheDocument();
expect(screen.queryByRole("radio", { name: /No key/ })).not.toBeInTheDocument();
});

it("should return to API key auth when the next target type has no no-key option", async () => {
const user = userEvent.setup();
render(
<TestWrapper>
<CreateTargetDialog {...defaultProps} />
</TestWrapper>
);

await selectTargetType("OpenAIChatTarget");
await user.click(screen.getByRole("radio", { name: /No key/ }));
await selectTargetType("AzureMLChatTarget");

expect(screen.getByRole("radio", { name: /API Key/ })).toBeChecked();
expect(screen.getByPlaceholderText("API key (stored in memory only)")).toBeInTheDocument();
});

it("should offer no-key auth for OpenAI targets when type metadata fails to load", async () => {
mockedTargetsApi.listTargetTypes.mockRejectedValueOnce(new Error("registry unavailable"));
render(
<TestWrapper>
<CreateTargetDialog {...defaultProps} />
</TestWrapper>
);

expect(await screen.findByText(/Target details could not be loaded/)).toBeInTheDocument();
await selectTargetType("OpenAIChatTarget");

expect(screen.getByRole("radio", { name: /No key/ })).toBeInTheDocument();
});

it("should warn the user when identity is selected for a non-Azure OpenAI endpoint", async () => {
const user = userEvent.setup();

Expand Down
61 changes: 48 additions & 13 deletions frontend/src/components/Config/CreateTargetDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {
} from '@/components/Parameters/parameterForm'
import { targetsApi } from '@/services/api'
import { toApiError } from '@/services/errors'
import type { TargetInstance, TargetTypeEntry } from '@/types'
import type { TargetAuthMode, TargetInstance, TargetTypeEntry } from '@/types'
import {
targetIdentifierHash,
targetModelName,
Expand Down Expand Up @@ -70,6 +70,15 @@ const FALLBACK_IDENTITY_TARGET_TYPES = new Set([
'AzureMLChatTarget',
])

const FALLBACK_NO_KEY_TARGET_TYPES = new Set([
'OpenAIChatTarget',
'OpenAICompletionTarget',
'OpenAIImageTarget',
'OpenAIVideoTarget',
'OpenAITTSTarget',
'OpenAIResponseTarget',
])

const TARGET_DISPLAY_NAMES: Record<string, string> = {
AzureMLChatTarget: 'Azure Machine Learning chat',
GitHubCopilotTarget: 'GitHub Copilot',
Expand Down Expand Up @@ -122,9 +131,14 @@ const FALLBACK_TARGET_TYPE_ENTRIES: TargetTypeEntry[] = FALLBACK_TARGET_TYPES.ma
}
))

type AuthMode = 'api_key' | 'identity'
type TypeMetadataStatus = 'loading' | 'loaded' | 'error'

const AUTH_MODE_LABELS: Record<TargetAuthMode, string> = {
api_key: 'API key',
identity: 'Microsoft Entra ID',
none: 'No key',
}

function getTargetDisplayName(targetType: string): string {
return TARGET_DISPLAY_NAMES[targetType] ?? targetType
}
Expand All @@ -136,9 +150,7 @@ return 'Supported authentication: GitHub token'
const authModes = entry.supported_auth_modes
if (authModes.length === 0) return null

const labels = authModes.map((mode) => (
mode === 'identity' ? 'Microsoft Entra ID' : 'API key'
))
const labels = authModes.map((mode) => AUTH_MODE_LABELS[mode])
return `Supported authentication: ${labels.join(' or ')}`
}

Expand All @@ -149,6 +161,10 @@ function defaultSupportsIdentity(targetType: string): boolean {
return FALLBACK_IDENTITY_TARGET_TYPES.has(targetType)
}

function defaultSupportsNoKey(targetType: string): boolean {
return FALLBACK_NO_KEY_TARGET_TYPES.has(targetType)
}

function getParameterLabel(name: string): string {
return name
.split('_')
Expand Down Expand Up @@ -264,7 +280,7 @@ export default function CreateTargetDialog({ open, onClose, onCreated, existingT
const [modelName, setModelName] = useState('')
const [hasDifferentUnderlying, setHasDifferentUnderlying] = useState(false)
const [underlyingModel, setUnderlyingModel] = useState('')
const [authMode, setAuthMode] = useState<AuthMode>('api_key')
const [authMode, setAuthMode] = useState<TargetAuthMode>('api_key')
const [apiKey, setApiKey] = useState('')
const [parameterValues, setParameterValues] = useState<Record<string, ParameterFormValue>>({})
const [submitting, setSubmitting] = useState(false)
Expand Down Expand Up @@ -384,12 +400,16 @@ export default function CreateTargetDialog({ open, onClose, onCreated, existingT
const supportsIdentity = targetTypeEntry
? targetTypeEntry.supported_auth_modes.includes('identity')
: defaultSupportsIdentity(targetType)
const showAuthField = targetType !== '' && supportsIdentity
const supportsNoKey = targetTypeEntry
? targetTypeEntry.supported_auth_modes.includes('none')
: defaultSupportsNoKey(targetType)
const showAuthField = targetType !== '' && (supportsIdentity || supportsNoKey)
const isIdentity = showAuthField && authMode === 'identity'
const isNoKey = showAuthField && authMode === 'none'
const endpointRequired = hasEndpointField
const modelNameRequired = Boolean(modelNameParameter?.required)
const underlyingModelRequired = Boolean(underlyingModelParameter?.required)
const apiKeyRequired = Boolean(apiKeyParameter?.required) && !isIdentity
const apiKeyRequired = Boolean(apiKeyParameter?.required) && !isIdentity && !isNoKey
const identityEndpointError: string | null = (() => {
if (!isIdentity || endpoint === '') return null
if (isOpenAi && !isAzureOpenAiEndpoint(endpoint)) {
Expand Down Expand Up @@ -580,7 +600,7 @@ export default function CreateTargetDialog({ open, onClose, onCreated, existingT
}
if (hasEndpointField && endpoint) params.endpoint = endpoint
if (hasModelNameField && modelName) params.model_name = modelName
if (hasApiKeyField && !isIdentity && apiKey) params.api_key = apiKey
if (hasApiKeyField && !isIdentity && !isNoKey && apiKey) params.api_key = apiKey

if (
(underlyingModelRequired || hasDifferentUnderlying)
Expand All @@ -594,6 +614,7 @@ export default function CreateTargetDialog({ open, onClose, onCreated, existingT
type: targetType,
params,
...(isIdentity ? { auth_mode: 'identity' as const } : {}),
...(isNoKey ? { auth_mode: 'none' as const } : {}),
})

resetForm()
Expand Down Expand Up @@ -662,7 +683,13 @@ export default function CreateTargetDialog({ open, onClose, onCreated, existingT
const nextSupportsIdentity = nextEntry
? nextEntry.supported_auth_modes.includes('identity')
: defaultSupportsIdentity(next)
if (!nextSupportsIdentity) {
const nextSupportsNoKey = nextEntry
? nextEntry.supported_auth_modes.includes('none')
: defaultSupportsNoKey(next)
if (
(authMode === 'identity' && !nextSupportsIdentity)
|| (authMode === 'none' && !nextSupportsNoKey)
) {
setAuthMode('api_key')
}
}}
Expand Down Expand Up @@ -978,8 +1005,11 @@ export default function CreateTargetDialog({ open, onClose, onCreated, existingT
<RadioGroup
value={authMode}
onChange={(_, data) => {
const next = data.value as AuthMode
const next = data.value as TargetAuthMode
setAuthMode(next)
if (next === 'none') {
setApiKey('')
}
if (next === 'identity') {
setApiKey('')
setParameterValues((current) => {
Expand All @@ -993,7 +1023,12 @@ export default function CreateTargetDialog({ open, onClose, onCreated, existingT
}}
>
<Radio value="api_key" label="API Key" />
<Radio value="identity" label="Identity-based (Microsoft Entra ID)" />
{supportsIdentity && (
<Radio value="identity" label="Identity-based (Microsoft Entra ID)" />
)}
{supportsNoKey && (
<Radio value="none" label="No key (for servers that need none)" />
)}
</RadioGroup>
</Field>
)}
Expand All @@ -1006,7 +1041,7 @@ export default function CreateTargetDialog({ open, onClose, onCreated, existingT
</MessageBar>
)}

{hasApiKeyField && !isIdentity && (
{hasApiKeyField && !isIdentity && !isNoKey && (
<Field
label="API Key"
hint={apiKeyParameter?.description || undefined}
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ export interface TargetListResponse {
export interface CreateTargetRequest {
type: string
params: Record<string, unknown>
auth_mode?: 'api_key' | 'identity'
auth_mode?: TargetAuthMode
}

// --- Initializers ---
Expand Down Expand Up @@ -700,10 +700,12 @@ export interface ConverterPreviewResponse {
steps: ConverterPreviewStep[]
}

export type TargetAuthMode = 'api_key' | 'identity' | 'none'

export interface TargetTypeEntry {
target_type: string
parameters: Parameter[]
supported_auth_modes: ('api_key' | 'identity')[]
supported_auth_modes: TargetAuthMode[]
description?: string | null
}

Expand Down
24 changes: 19 additions & 5 deletions pyrit/auth/openai_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
from pyrit.common.auth_mode import AuthMode
from pyrit.common.deprecation import print_deprecation_message

# The OpenAI SDK requires a key, so a no-key target passes this fixed, non-secret value.
# Servers that need no key ignore the resulting Authorization header.
_NO_KEY_PLACEHOLDER = "not-needed"


def resolve_openai_auth(
*,
Expand All @@ -26,7 +30,9 @@ def resolve_openai_auth(
api_key_environment_variable (str): Environment variable to use when ``api_key`` is not provided.
auth_mode (AuthMode): ``"identity"`` authenticates with a Microsoft Entra ID token minted for
the endpoint; it ignores the API key environment variable and rejects an explicit
``api_key``. ``"api_key"`` (the default) resolves a token-provider callable, then an
``api_key``. ``"none"`` is for endpoints that need no key: it reads no key, rejects an
explicit ``api_key`` and returns a fixed, non-secret placeholder because the OpenAI SDK
requires one. ``"api_key"`` (the default) resolves a token-provider callable, then an
explicit key, then the environment variable, and finally falls back to Entra ID on a
recognized Azure OpenAI endpoint. That last fallback is deprecated and is removed in
1.4.0, after which identity auth must be requested explicitly.
Expand All @@ -35,11 +41,19 @@ def resolve_openai_auth(
str | Callable[[], Awaitable[str]]: API key string or async-compatible token provider.

Raises:
ValueError: If identity auth is requested alongside an explicit ``api_key``, if identity auth
is requested for an endpoint that is not a recognized Azure OpenAI endpoint, or if
``"api_key"`` auth is requested and no key is available for an endpoint that is not a
recognized Azure OpenAI endpoint.
ValueError: If identity or no-key auth is requested alongside an explicit ``api_key``, if
identity auth is requested for an endpoint that is not a recognized Azure OpenAI
endpoint, or if ``"api_key"`` auth is requested and no key is available for an endpoint
that is not a recognized Azure OpenAI endpoint.
"""
if auth_mode == "none":
if api_key is not None:
raise ValueError(
'auth_mode="none" cannot be combined with an explicit api_key. Omit api_key for an endpoint '
'that needs no key, or pass auth_mode="api_key" to send the key you supplied.'
)
return _NO_KEY_PLACEHOLDER

# Identity is an explicit caller choice, so it must never be silently downgraded to a key
# that merely happens to be present in the environment.
if auth_mode == "identity":
Expand Down
4 changes: 3 additions & 1 deletion pyrit/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,9 @@ API callers are trusted operators, so these are intentional exceptions:
the deployment instead.
- Some targets use this server's credentials when the caller gives no key: an API key from the
server's environment, sent even to an endpoint the caller chose, or a token for the server's
Azure identity, sent to the Azure endpoint or container the caller chose.
Azure identity, sent to the Azure endpoint or container the caller chose. For an
OpenAI-compatible server that needs no key, set `auth_mode` to `none` so the target uses none
of these.
- Registered targets, converters, and scorers are shared: any API caller can use them, whoever
created them.
- Prompt content is not filtered; it is adversarial test data by design.
Expand Down
Loading
Loading