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
10 changes: 10 additions & 0 deletions docs/reference/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,16 @@ hostname, so list the web hostname explicitly.

Requires `az login` to have been run beforehand.

Azure CLI must be discoverable through an absolute `PATH` entry that does not
identify the current working directory itself. Specify ignores relative `PATH`
entries and current-directory entries, including symlink and case aliases.
Absolute entries for subdirectories, such as `<cwd>/.venv/bin`, remain eligible;
this is not a blanket exclusion of project-local executables.

Executable symlinks targeting the current directory and paths that cannot be
inspected are also skipped. If no eligible Azure CLI executable is found, this
provider returns no token; it never falls back to running a bare `az` command.

**Example — Azure AD service principal (CI/automation):**

```json
Expand Down
54 changes: 35 additions & 19 deletions src/specify_cli/authentication/azure_devops.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import os
import shutil
import subprocess
import sys
from typing import TYPE_CHECKING

from .._download_security import MAX_JSON_METADATA_BYTES, read_response_limited
Expand All @@ -33,6 +34,36 @@ def _extract_token(payload: object, key: str) -> str | None:
return token.strip() or None


def _find_az_cli() -> str | None:
"""Find Azure CLI without searching the current directory or its aliases."""
names = ["az"]
if sys.platform == "win32":
# Python 3.11's which() does not expand PATHEXT for absolute paths.
extensions = (os.environ.get("PATHEXT") or ".COM;.EXE;.BAT;.CMD").split(";")
names = ["az" + extension for extension in extensions if extension]

cwd = os.getcwd()
for directory in os.get_exec_path():
if not os.path.isabs(directory):
continue
try:
if os.path.samefile(directory, cwd):
continue
except OSError:
continue
for name in names:
resolved = shutil.which(os.path.join(directory, name))
if not resolved or not os.path.isabs(resolved):
continue
try:
if os.path.samefile(os.path.dirname(os.path.realpath(resolved)), cwd):
continue
except OSError:
continue
return resolved
return None


class AzureDevOpsAuth(AuthProvider):
"""Azure DevOps authentication provider.

Expand Down Expand Up @@ -72,25 +103,10 @@ def resolve_token(self, entry: AuthConfigEntry) -> str | None:
def _acquire_via_az_cli() -> str | None:
"""Run ``az account get-access-token`` and return the access token."""
try:
# Windows: ``subprocess.run`` calls ``CreateProcess``, which does
# not consult ``PATHEXT``, so a bare ``"az"`` (installed as
# ``az.cmd``) fails with ``WinError 2`` even after ``az login``.
# Resolve via ``shutil.which`` (which honors ``PATHEXT``) so the
# ``.cmd`` shim works. On POSIX this is a harmless lookup that
# returns the same executable.
#
# Require an ABSOLUTE result: on Windows ``shutil.which`` prepends
# the current directory to the search path (unless
# ``NoDefaultCurrentDirectoryInExePath`` is set), so a stray
# ``.\az.cmd`` in the working directory would otherwise be resolved
# ahead of the real Azure CLI and run for a credential operation. A
# legitimate install always resolves to an absolute path, so this
# costs nothing; falling back to the bare ``"az"`` preserves the
# prior behavior (and the existing OSError path) when ``az`` is
# absent.
resolved = shutil.which("az")
az = resolved if resolved and os.path.isabs(resolved) else "az"
result = subprocess.run( # noqa: S603, S607
az = _find_az_cli()
if az is None:
return None
result = subprocess.run( # noqa: S603
[
az,
"account",
Expand Down
Loading
Loading