Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
0abe8fe
FIX: Make identity auth an explicit choice instead of an inferred one
Copilot Oct 6, 2026
9e3c184
FIX: Make request-level auth_mode authoritative over params
Copilot Oct 7, 2026
0c780de
DOC: Document explicit auth_mode="identity" for Entra auth
Copilot Oct 7, 2026
9e51bbd
FIX: Reconcile explicit auth_mode with the get_auth_mode_parameters hook
Copilot Oct 7, 2026
19f2f80
FIX: Drop redundant AuthMode cast flagged by ty
Copilot Oct 7, 2026
8139ebd
Merge branch 'main' into hannahwestra25-explicit-identity-auth
hannahwestra25 Oct 8, 2026
da72cf5
FIX: remove the implicit Entra fallback so auth intent is always expl…
Copilot Oct 8, 2026
85bbaa9
FIX: address review feedback on explicit auth mode
Copilot Oct 9, 2026
22efef7
Merge branch 'main' into hannahwestra25-explicit-identity-auth
hannahwestra25 Oct 9, 2026
71e65b3
DOC: correct .env_example Entra guidance for explicit auth_mode
Copilot Oct 9, 2026
43e2691
FIX: deprecate rather than remove the implicit Entra fallback
Copilot Oct 9, 2026
a6f04ce
Merge branch 'main' into hannahwestra25-explicit-identity-auth
hannahwestra25 Oct 13, 2026
9314360
Merge branch 'main' into hannahwestra25-explicit-identity-auth
hannahwestra25 Oct 13, 2026
d334a93
Merge branch 'main' into hannahwestra25-explicit-identity-auth
Copilot Oct 14, 2026
c517b10
Merge branch 'main' into hannahwestra25-explicit-identity-auth
hannahwestra25 Oct 14, 2026
92da4b1
Merge branch 'main' into hannahwestra25-explicit-identity-auth
hannahwestra25 Oct 14, 2026
850d173
Merge branch 'main' into hannahwestra25-explicit-identity-auth
Copilot Oct 15, 2026
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
9 changes: 6 additions & 3 deletions .env_example
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,12 @@
# other OpenAI-compatible endpoint. See doc/setup/populating_secrets.md
# for provider-specific examples.
#
# If you are using Entra authentication for Azure resources,
# keys for those resources are not needed. PyRIT auto-detects: if an API key
# is set, it uses key auth; otherwise it falls back to Entra ID automatically.
# For Azure resources you can authenticate with Microsoft Entra ID instead of a
# key. Make it an explicit choice: run `az login` and pass auth_mode="identity"
# to the target, e.g. OpenAIChatTarget(endpoint=..., auth_mode="identity").
# Leaving the key unset still selects Entra ID automatically, but that fallback
# is deprecated and is removed in 1.4.0.
# See doc/code/setup/1_configuration.ipynb for details.
#
# ============================================================================

Expand Down
19 changes: 18 additions & 1 deletion doc/code/setup/1_configuration.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,24 @@
"\n",
" ```bash\n",
" az login\n",
" ```"
" ```\n",
"\n",
"### Choosing between key and Entra auth\n",
"\n",
"Authentication is explicit. `auth_mode=\"api_key\"` (the default) resolves a credential in this order: a token provider callable passed as `api_key`, an explicit `api_key` string, then the target's API key environment variable. If none of those yield a key and the endpoint is a recognized Azure host, it falls back to an Entra token provider; that fallback emits a `DeprecationWarning` and is removed in 1.4.0.\n",
"\n",
"Pass `auth_mode=\"identity\"` when you want Entra auth. It ignores the target's API key environment variable entirely, so an unrelated key in your `.env` can no longer override your choice. It raises a `ValueError` if you also pass an explicit `api_key`, since identity mints its own token and would silently discard the credential you supplied. It also raises a `ValueError` if the endpoint is not a recognized Azure endpoint rather than minting a token for an unknown host.\n",
"\n",
"```python\n",
"target = OpenAIChatTarget(\n",
" endpoint=os.environ[\"OPENAI_CHAT_ENDPOINT\"],\n",
" auth_mode=\"identity\",\n",
")\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",
"\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
17 changes: 17 additions & 0 deletions doc/code/setup/1_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,23 @@
# ```bash
# az login
# ```
#
# ### Choosing between key and Entra auth
#
# Authentication is explicit. `auth_mode="api_key"` (the default) resolves a credential in this order: a token provider callable passed as `api_key`, an explicit `api_key` string, then the target's API key environment variable. If none of those yield a key and the endpoint is a recognized Azure host, it falls back to an Entra token provider; that fallback emits a `DeprecationWarning` and is removed in 1.4.0.
#
# Pass `auth_mode="identity"` when you want Entra auth. It ignores the target's API key environment variable entirely, so an unrelated key in your `.env` can no longer override your choice. It raises a `ValueError` if you also pass an explicit `api_key`, since identity mints its own token and would silently discard the credential you supplied. It also raises a `ValueError` if the endpoint is not a recognized Azure endpoint rather than minting a token for an unknown host.
#
# ```python
# target = OpenAIChatTarget(
# endpoint=os.environ["OPENAI_CHAT_ENDPOINT"],
# auth_mode="identity",
# )
# ```
#
# `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.
#
# > **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).

# %% [markdown]
# ## Choosing a database
Expand Down
49 changes: 45 additions & 4 deletions pyrit/auth/openai_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,58 @@

from pyrit.auth.azure_auth import ensure_async_token_provider, get_azure_openai_auth, is_azure_openai_endpoint
from pyrit.common import default_values
from pyrit.common.auth_mode import AuthMode
from pyrit.common.deprecation import print_deprecation_message


def resolve_openai_auth(
*,
endpoint: str,
api_key: str | Callable[[], str | Awaitable[str]] | None,
api_key_environment_variable: str,
auth_mode: AuthMode = "api_key",
) -> str | Callable[[], Awaitable[str]]:
"""
Resolve OpenAI authentication from a key, environment variable, or Azure Entra fallback.
Resolve OpenAI authentication from an explicit identity choice, a key, or an environment variable.

Args:
endpoint (str): The OpenAI-compatible endpoint URL.
api_key (str | Callable[[], str | Awaitable[str]] | None): The explicit API key or token provider.
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
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.

Returns:
str | Callable[[], Awaitable[str]]: API key string or async-compatible token provider.

Raises:
ValueError: If no key is provided and the endpoint is not a recognized Azure OpenAI endpoint.
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.
"""
# 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":
if api_key is not None:
raise ValueError(
'auth_mode="identity" cannot be combined with an explicit api_key, because identity auth '
"mints its own Microsoft Entra ID token and would silently ignore the key or token provider "
"you supplied. Omit api_key to authenticate with an ambient Azure identity, or pass "
'auth_mode="api_key" to authenticate with the key or token provider you supplied.'
)
if not is_azure_openai_endpoint(endpoint):
raise ValueError(
f"Identity-based authentication requires a recognized Azure OpenAI / AI Foundry endpoint, "
f"but got '{endpoint}'. Pass auth_mode=\"api_key\" for this endpoint, supplying either a key "
"or your own token provider callable as api_key."
)
return get_azure_openai_auth(endpoint)

if api_key is not None and callable(api_key):
return cast("str | Callable[[], Awaitable[str]]", ensure_async_token_provider(api_key))

Expand All @@ -37,10 +67,21 @@ def resolve_openai_auth(
if api_key_value:
return api_key_value

# Keyless configurations against a recognized Azure endpoint predate explicit auth modes, so the
# implicit Entra fallback stays until 1.4.0 rather than breaking them at the next minor release.
if is_azure_openai_endpoint(endpoint):
print_deprecation_message(
old_item=(
"Falling back to Microsoft Entra ID authentication for Azure OpenAI endpoints when no API key "
"is configured"
),
new_item='auth_mode="identity"',
removed_in="1.4.0",
)
return get_azure_openai_auth(endpoint)

raise ValueError(
f"Environment variable {api_key_environment_variable} is required for non-Azure endpoints. "
"For recognized Azure OpenAI / AI Foundry endpoints, Entra ID authentication is used automatically."
f"No API key available for endpoint '{endpoint}'. Set the {api_key_environment_variable} environment "
'variable, pass api_key explicitly, or pass auth_mode="identity" to authenticate with Microsoft '
"Entra ID on a recognized Azure OpenAI / AI Foundry endpoint."
)
59 changes: 39 additions & 20 deletions pyrit/backend/services/target_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
import logging
import uuid
from functools import lru_cache
from typing import Any, Literal
from typing import Any

from pyrit.backend.mappers.target_mappers import target_object_to_instance
from pyrit.backend.models.common import PaginationInfo
Expand All @@ -27,6 +27,7 @@
TargetTypeResponse,
)
from pyrit.common import REQUIRED_VALUE, default_values
from pyrit.common.auth_mode import AUTH_MODES, AuthMode
from pyrit.models.catalog.target import TargetInstance
from pyrit.models.parameter import Parameter
from pyrit.prompt_target import GitHubCopilotTarget, PromptTarget
Expand All @@ -43,6 +44,9 @@
"PromptShieldTarget": frozenset({"endpoint"}),
}

# Constructor parameter through which a target accepts an explicit auth mode.
_AUTH_MODE_PARAM = "auth_mode"


class TargetService:
"""
Expand Down Expand Up @@ -139,25 +143,24 @@ def get_target_object(self, *, target_registry_name: str) -> Any | None:
return self._registry.instances.get(target_registry_name)

@staticmethod
def _get_supported_auth_modes(auth_modes: tuple[str, ...]) -> list[Literal["api_key", "identity"]]:
def _get_supported_auth_modes(auth_modes: tuple[str, ...]) -> list[AuthMode]:
"""
Validate and narrow registry authentication modes for the type response.

Args:
auth_modes (tuple[str, ...]): Authentication modes declared by a target class.

Returns:
list[Literal["api_key", "identity"]]: Validated authentication modes.
list[AuthMode]: Validated authentication modes.

Raises:
ValueError: If a target class declares an unsupported authentication mode.
"""
supported_auth_modes: list[Literal["api_key", "identity"]] = []
supported_auth_modes: list[AuthMode] = []
for auth_mode in auth_modes:
if auth_mode == "api_key" or auth_mode == "identity":
supported_auth_modes.append(auth_mode)
continue
raise ValueError(f"Unsupported target authentication mode: {auth_mode!r}")
if auth_mode not in AUTH_MODES:
raise ValueError(f"Unsupported target authentication mode: {auth_mode!r}")
supported_auth_modes.append(auth_mode)
return supported_auth_modes

def _project_target_parameters(self, *, target_type: str, parameters: tuple[Parameter, ...]) -> list[Parameter]:
Expand Down Expand Up @@ -285,15 +288,19 @@ async def create_target_async(
reference resolution, and construction are owned by the
``TargetRegistry``. Endpoint trust and identity token minting are owned
by the target classes themselves. This service enforces the
request-level contract: for ``identity`` it confirms the target
supports it and omits the api_key plus any registry-flagged
identity-conflicting parameters so the target validates its own
endpoint and authenticates itself, and a target that uploads local files
gets the server's upload folder, which callers cannot choose. GitHub Copilot
requires an explicit or environment token rather than SDK login discovery in
CoPyRIT. A target whose construction runs model repository code is built only
for an administrator. The response is built before the target is registered,
so a failed request leaves no registered target.
request-level contract: it rejects an ``auth_mode`` smuggled through
``params``, and for ``identity`` it confirms the target supports it and
omits the api_key plus any registry-flagged identity-conflicting
parameters so the target validates its own endpoint and authenticates
itself. The request-level ``auth_mode`` is authoritative and is forwarded
via ``get_auth_mode_parameters`` to targets that accept it, so the choice
is explicit rather than inferred from a missing key. A target that uploads
local files gets the server's upload folder, which callers cannot choose.
GitHub Copilot requires an explicit or environment token rather than SDK
login discovery in CoPyRIT. A target whose construction runs model
repository code is built only for an administrator. The response is built
before the target is registered, so a failed request leaves no registered
target.

Args:
request: The create target request with type, params, and auth_mode.
Expand All @@ -305,8 +312,9 @@ async def create_target_async(
TargetInstance with the new target's details.

Raises:
ValueError: If the target type is not registered, identity auth is
requested but unsupported by the target type, or a target that
ValueError: If the target type is not registered, ``params`` carries an
``auth_mode`` that conflicts with the request-level choice, identity
auth is requested but unsupported by the target type, or a target that
uploads local files is requested with its upload folder or while
the server configures none. Construction errors (unknown params,
incompatible inner targets, unrecognized identity endpoints) are
Expand All @@ -321,10 +329,20 @@ async def create_target_async(
target_cls = self._registry.get_class(request.type)
params: dict[str, Any] = dict(request.params)

# auth_mode is also a constructor parameter, so the registry would otherwise accept it
# inside params as a second, competing channel that bypasses the checks below.
params_auth_mode = params.get(_AUTH_MODE_PARAM)
if params_auth_mode is not None and params_auth_mode != request.auth_mode:
raise ValueError(
f"Conflicting authentication modes: request auth_mode is '{request.auth_mode}' but "
f"params['{_AUTH_MODE_PARAM}'] is '{params_auth_mode}'. Set the request-level auth_mode only."
)

if request.auth_mode == "identity":
if "identity" not in target_cls.supported_auth_modes:
raise ValueError(f"Target type '{request.type}' does not support identity-based authentication.")
# Omit any api_key so the target validates its own endpoint and authenticates itself.
# Omitting the key alone is ambiguous — every auth resolver reads the api-key env var
# when no key is passed — so also state the choice explicitly where the target accepts it.
params.pop("api_key", None)
# Omit any other parameter the registry metadata marks as conflicting with
# identity-based auth (e.g. AzureBlobStorageTarget's sas_token), so a caller
Expand All @@ -334,6 +352,7 @@ async def create_target_async(
for parameter in metadata.parameters:
if parameter.identity_conflicting:
params.pop(parameter.name, None)

params.update(target_cls.get_auth_mode_parameters(auth_mode=request.auth_mode))
self._apply_upload_directory(
target_type=request.type, target_cls=target_cls, params=params, upload_directory=upload_directory
Expand Down
3 changes: 3 additions & 0 deletions pyrit/common/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
reset_default_values,
set_default_value,
)
from pyrit.common.auth_mode import AUTH_MODES, AuthMode
from pyrit.common.brick_contract import enforce_keyword_only_init, forward_init_parameters
from pyrit.common.default_values import get_non_required_value, get_required_value
from pyrit.common.deprecation import print_deprecation_message
Expand All @@ -48,6 +49,8 @@
_LAZY_EXPORTS: dict[str, str | tuple[str, str | None]] = {
"apply_defaults": "pyrit.common.apply_defaults",
"apply_defaults_to_method": "pyrit.common.apply_defaults",
"AUTH_MODES": "pyrit.common.auth_mode",
"AuthMode": "pyrit.common.auth_mode",
"combine_dict": "pyrit.common.utils",
"combine_list": "pyrit.common.utils",
"DefaultValueScope": "pyrit.common.apply_defaults",
Expand Down
33 changes: 33 additions & 0 deletions pyrit/common/auth_mode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.

"""
Canonical credential-selection mode shared by auth resolvers and targets.

Lives in ``pyrit.common`` rather than ``pyrit.prompt_target`` because
``pyrit.auth`` resolvers consume it and must not depend on the target layer.

Not to be confused with ``pyrit.cli._auth.AuthMode``, which names the *Azure
credential flow* the CLI should use (``"auto"`` / ``"azure_cli"`` /
``"device_code"`` / ``"none"``).
"""

from typing import Literal

__all__ = ["AUTH_MODES", "AuthMode"]

#: How a component chooses its credential.
#:
#: ``identity`` is an explicit caller choice: the key and its environment variable are
#: skipped entirely and the component authenticates with an ambient Azure identity
#: (e.g. a Microsoft Entra ID token minted for its own endpoint).
#:
#: ``api_key`` resolves a key from the explicit argument or the component's API key
#: environment variable. The OpenAI, Azure ML and Prompt Shield resolvers require one
#: and raise when neither is available. ``AzureBlobStorageTarget`` still falls back to
#: an ambient Azure identity when no SAS token is configured, but that fallback is
#: deprecated and is removed in 1.4.0, after which every component treats the two modes
#: as disjoint.
AuthMode = Literal["api_key", "identity"]

AUTH_MODES: tuple[AuthMode, ...] = ("api_key", "identity")
Loading
Loading