diff --git a/src/artifacts-helper/NOTES.md b/src/artifacts-helper/NOTES.md index 5e83843..056b7fe 100644 --- a/src/artifacts-helper/NOTES.md +++ b/src/artifacts-helper/NOTES.md @@ -1,6 +1,10 @@ This installs [Azure Artifacts Credential Provider](https://github.com/microsoft/artifacts-credprovider) -and optionally configures shims which shadow `dotnet`, `nuget`, `npm`, `yarn`, `rush`, and `pnpm`. -These dynamically sets an authentication token for pulling artifacts from a feed before running the command. +and optionally configures shims which shadow `dotnet`, `nuget`, `npm`, `yarn`, `rush`, `pnpm`, and `az`. +These dynamically set an authentication token for pulling artifacts from a feed before running the command. + +The `az` shim specifically intercepts `az account get-access-token` requests and uses the `azure-auth-helper` +to acquire tokens via the ado-codespaces-auth VS Code extension. This enables `DefaultAzureCredential`'s +`AzureCliCredential` to work in Codespaces without requiring `az login`. For `npm`, `yarn`, `rush`, and `pnpm` this requires that your `~/.npmrc` file is configured to use the ${ARTIFACTS_ACCESSTOKEN} environment variable for the `authToken`. A helper script has been added that you can use to write your `~/.npmrc` diff --git a/src/artifacts-helper/devcontainer-feature.json b/src/artifacts-helper/devcontainer-feature.json index 2d7566b..a3d6049 100644 --- a/src/artifacts-helper/devcontainer-feature.json +++ b/src/artifacts-helper/devcontainer-feature.json @@ -1,7 +1,7 @@ { "name": "Azure Artifacts Credential Helper", "id": "artifacts-helper", - "version": "3.0.2", + "version": "3.0.3", "description": "Configures Codespace to authenticate with Azure Artifact feeds", "options": { "nugetURIPrefixes": { @@ -49,6 +49,11 @@ "default": true, "description": "Create alias for pnpm" }, + "azAlias": { + "type": "boolean", + "default": true, + "description": "Create alias for az (Azure CLI)" + }, "shimDirectory": { "type": "string", "default": "/usr/local/share/codespace-shims", diff --git a/src/artifacts-helper/install.sh b/src/artifacts-helper/install.sh index 5386350..2fb3f09 100755 --- a/src/artifacts-helper/install.sh +++ b/src/artifacts-helper/install.sh @@ -11,6 +11,7 @@ ALIAS_YARN="${YARNALIAS:-"true"}" ALIAS_NPX="${NPXALIAS:-"true"}" ALIAS_RUSH="${RUSHALIAS:-"true"}" ALIAS_PNPM="${PNPMALIAS:-"true"}" +ALIAS_AZ="${AZALIAS:-"true"}" INSTALL_PIP_HELPER="${PYTHON:-"false"}" SHIM_DIRECTORY="${SHIMDIRECTORY:-"/usr/local/share/codespace-shims/"}" @@ -39,6 +40,9 @@ if [ "${ALIAS_PNPM}" = "true" ]; then ALIASES_ARR+=('pnpm') ALIASES_ARR+=('pnpx') fi +if [ "${ALIAS_AZ}" = "true" ]; then + ALIASES_ARR+=('az') +fi # Source /etc/os-release to get OS info . /etc/os-release diff --git a/src/artifacts-helper/scripts/az b/src/artifacts-helper/scripts/az new file mode 100644 index 0000000..89156fd --- /dev/null +++ b/src/artifacts-helper/scripts/az @@ -0,0 +1,109 @@ +#!/bin/bash +# Azure CLI shim for GitHub Codespaces +# Intercepts 'az account get-access-token' requests and uses azure-auth-helper +# to acquire tokens via the ado-codespaces-auth VS Code extension. + +# If ACTIONS_ID_TOKEN_REQUEST_URL is set, we are in GitHub Actions - skip interception +if [ -n "${ACTIONS_ID_TOKEN_REQUEST_URL}" ]; then + source "$(dirname "$0")"/resolve-shim.sh + AZ_EXE="$(resolve_shim)" + exec "${AZ_EXE}" "$@" +fi + +source "$(dirname "$0")"/resolve-shim.sh + +# Well-known resource type mappings (az account get-access-token --resource-type) +declare -A RESOURCE_TYPE_MAP=( + ["arm"]="https://management.azure.com" + ["aad-graph"]="https://graph.windows.net" + ["ms-graph"]="https://graph.microsoft.com" + ["batch"]="https://batch.core.windows.net" + ["data-lake"]="https://datalake.azure.net" + ["media"]="https://rest.media.azure.net" + ["oss-rdbms"]="https://ossrdbms-aad.database.windows.net" +) + +# Check if this is a get-access-token request that we should intercept +if [[ "$1" == "account" && "$2" == "get-access-token" ]]; then + resource="" + scope="" + resource_type="" + prev="" + + for arg in "${@:3}"; do + case "$arg" in + --resource=*) resource="${arg#--resource=}" ;; + --scope=*) scope="${arg#--scope=}" ;; + --resource-type=*) resource_type="${arg#--resource-type=}" ;; + *) + case "$prev" in + --resource) resource="$arg" ;; + --scope) scope="$arg" ;; + --resource-type) resource_type="$arg" ;; + esac + ;; + esac + prev="$arg" + done + + # Resolve resource-type to resource URL if specified + if [[ -n "$resource_type" && -z "$resource" ]]; then + resource="${RESOURCE_TYPE_MAP[$resource_type]}" + fi + + # Determine the scope to request + request_scope="" + if [[ -n "$scope" ]]; then + request_scope="$scope" + elif [[ -n "$resource" ]]; then + if [[ "$resource" == *"/.default" ]]; then + request_scope="$resource" + else + request_scope="${resource}/.default" + fi + fi + + # If we have a scope and azure-auth-helper exists, use it + if [[ -n "$request_scope" && -f "${HOME}/azure-auth-helper" ]]; then + token=$("${HOME}/azure-auth-helper" get-access-token "$request_scope" 2>/dev/null) + if [[ $? -eq 0 && -n "$token" ]]; then + # Escape token for safe JSON embedding (handle backslashes and quotes) + escaped_token="${token//\\/\\\\}" + escaped_token="${escaped_token//\"/\\\"}" + + # Calculate expiry timestamps (conservative 1 hour estimate) + # expires_on = POSIX timestamp, expiresOn = local datetime + if date --version >/dev/null 2>&1; then + # GNU date (Linux) + expires_on=$(date -d "+1 hour" "+%s") + expires_on_datetime=$(date -d "+1 hour" "+%Y-%m-%d %H:%M:%S.000000") + else + # BSD date (macOS) + expires_on=$(date -v+1H "+%s") + expires_on_datetime=$(date -v+1H "+%Y-%m-%d %H:%M:%S.000000") + fi + + # Return in az CLI JSON format (matching real az CLI output) + cat <&2 + exit 1 +fi diff --git a/test/artifacts-helper/scenarios.json b/test/artifacts-helper/scenarios.json index 516c9af..e3ca512 100644 --- a/test/artifacts-helper/scenarios.json +++ b/test/artifacts-helper/scenarios.json @@ -70,5 +70,14 @@ "nugetAlias": true } } + }, + "test_az_shim": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "features": { + "ghcr.io/devcontainers/features/azure-cli:1": {}, + "artifacts-helper": { + "azAlias": true + } + } } } \ No newline at end of file diff --git a/test/artifacts-helper/test_az_shim.sh b/test/artifacts-helper/test_az_shim.sh new file mode 100755 index 0000000..4b224c2 --- /dev/null +++ b/test/artifacts-helper/test_az_shim.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash + +# Import test library bundled with the devcontainer CLI +source dev-container-features-test-lib || exit 1 + +# Test that az shim is installed +check "az shim exists" test -f /usr/local/share/codespace-shims/az +check "az shim is executable" test -x /usr/local/share/codespace-shims/az + +# Test that az shim sources resolve-shim.sh +check "az shim sources resolve-shim.sh" grep -q 'source.*resolve-shim.sh' /usr/local/share/codespace-shims/az + +# Test GitHub Actions environment detection +check "az shim has GitHub Actions detection" grep -q 'ACTIONS_ID_TOKEN_REQUEST_URL' /usr/local/share/codespace-shims/az + +# Test that az shim intercepts get-access-token command +check "az shim intercepts get-access-token" grep -q 'get-access-token' /usr/local/share/codespace-shims/az + +# Test argument parsing handles both formats (--resource value and --resource=value) +check "az shim handles equals format args" grep -q '\-\-resource=\*' /usr/local/share/codespace-shims/az +check "az shim handles space-separated args" grep -q '\-\-resource)' /usr/local/share/codespace-shims/az + +# Test that az shim falls back to real az CLI for other commands +TEST_HOME=$(mktemp -d) +check "az shim falls back for non-token commands" bash -c ' + export HOME='"$TEST_HOME"' + # az --version should pass through to real az CLI (if installed) + # or fail gracefully if az is not installed + output=$(/usr/local/share/codespace-shims/az --version 2>&1) || true + # Check that it either shows az version or "not found" error - both are valid + echo "$output" | grep -qE "(azure-cli|Azure CLI|not found)" && echo "SUCCESS" || echo "FAILED" +' | grep -q "SUCCESS" + +# Test that az shim handles missing azure-auth-helper gracefully +check "az shim handles missing azure-auth-helper" bash -c ' + export HOME='"$TEST_HOME"' + # Remove azure-auth-helper if it exists + rm -f "${HOME}/azure-auth-helper" + # Call az account get-access-token - should fall through to real az + # (which will fail, but shim should not crash) + /usr/local/share/codespace-shims/az account get-access-token --resource https://management.azure.com 2>&1 || true + # If we get here, the shim handled it gracefully + echo "completed" +' | grep -q "completed" + +# Test that az shim returns proper JSON format when azure-auth-helper exists +check "az shim returns valid JSON format" bash -c ' + export HOME='"$TEST_HOME"' + # Create a mock azure-auth-helper that returns a test token + cat > "${HOME}/azure-auth-helper" << '\''HELPER'\'' +#!/bin/bash +echo "test-token-12345" +HELPER + chmod +x "${HOME}/azure-auth-helper" + + # Call the shim and verify JSON output + output=$(/usr/local/share/codespace-shims/az account get-access-token --resource https://management.azure.com 2>&1) + + # Check that output contains expected JSON fields + echo "$output" | grep -q "accessToken" && \ + echo "$output" | grep -q "tokenType" && \ + echo "$output" | grep -q "Bearer" && \ + echo "SUCCESS" || echo "FAILED" +' | grep -q "SUCCESS" + +# Test GitHub Actions bypass (simulate by setting the env var) +check "az shim bypasses interception in GitHub Actions" bash -c ' + export HOME='"$TEST_HOME"' + export ACTIONS_ID_TOKEN_REQUEST_URL="https://example.com/token" + # In Actions mode, shim should skip interception and call real az directly + # (will fail if az not installed, but should not attempt token interception) + output=$(/usr/local/share/codespace-shims/az account get-access-token --resource https://management.azure.com 2>&1) || true + # Should NOT contain our mock token (which means bypass worked) + echo "$output" | grep -qv "test-token-12345" && echo "SUCCESS" || echo "FAILED" +' | grep -q "SUCCESS" + +# Cleanup +rm -rf "$TEST_HOME" + +# Report results +reportResults