Skip to content
Closed
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,10 @@ Finalize tasks also expose `version` and `cacheHit` as named outputs. Use `$(set

Without `registryUrl`, the runtime preserves existing project `.npmrc` auth entries but does not generate token entries for its registry URLs. Set `registryUrl` to generate auth configuration for a registry. Referenced custom token variables are propagated as secret pipeline variables, not public outputs. Pass custom secret mappings through `authEnv`, for example `authEnv: { CUSTOM_TOKEN: "$(CUSTOM_TOKEN)" }`. Azure does not automatically put secret pipeline variables in task environments.

`authEnv` accepts `TOKEN`, `PASSWORD`, `SECRET`, `KEY`, or names ending in `_TOKEN`, `_PASSWORD`, `_SECRET`, or `_KEY`. Names can contain only ASCII letters, digits, and underscores, cannot start with a digit, and must be unique regardless of case. Runtime and CI namespaces, such as `NODE_*` and `SETUP_VP_*`, are reserved. Supported credential exceptions include `NODE_AUTH_TOKEN`, `GITHUB_TOKEN`, `CI_JOB_TOKEN`, `SYSTEM_ACCESSTOKEN`, `YARN_NPM_AUTH_TOKEN`, and `YARN_NPM_AUTH_IDENT`. Other names are rejected before installation.

For a secret with a different name, use an alias such as `authEnv: { CUSTOM_TOKEN: "$(MY_CREDENTIAL)" }` and reference `${CUSTOM_TOKEN}` in `.npmrc`. Custom credentials reach the install process after validation; unresolved secret macros are treated as missing credentials.

### Azure Notes

- The template supports Microsoft-hosted Linux, macOS, and Windows agents.
Expand Down
14 changes: 12 additions & 2 deletions azure/setup-vp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,12 @@ steps:
SETUP_VP_REGISTRY_URL: ${{ parameters.registryUrl }}
SETUP_VP_SCOPE: ${{ parameters.scope }}
NODE_AUTH_TOKEN: $(NODE_AUTH_TOKEN)
${{ insert }}: ${{ parameters.authEnv }}
# Keep credential mappings inert until the runtime validates their names.
# Preserve original names when Windows folds environment keys. Escape
# dollars so Azure cannot expand secret macros inside this JSON metadata.
SETUP_VP_AUTH_ENV: ${{ replace(convertToJson(parameters.authEnv), '$', '\u0024') }}
${{ each pair in parameters.authEnv }}:
${{ format('SETUP_VP_AUTH_ENV_{0}', pair.key) }}: ${{ pair.value }}
SETUP_VP_CACHE: ${{ iif(eq(parameters.cache, true), 'true', 'false') }}
SETUP_VP_CACHE_DEPENDENCY_PATH: ${{ parameters.cacheDependencyPath }}

Expand All @@ -171,6 +176,11 @@ steps:
SETUP_VP_REGISTRY_URL: ${{ parameters.registryUrl }}
SETUP_VP_SCOPE: ${{ parameters.scope }}
NODE_AUTH_TOKEN: $(NODE_AUTH_TOKEN)
${{ insert }}: ${{ parameters.authEnv }}
# Keep credential mappings inert until the runtime validates their names.
# Preserve original names when Windows folds environment keys. Escape
# dollars so Azure cannot expand secret macros inside this JSON metadata.
SETUP_VP_AUTH_ENV: ${{ replace(convertToJson(parameters.authEnv), '$', '\u0024') }}
${{ each pair in parameters.authEnv }}:
${{ format('SETUP_VP_AUTH_ENV_{0}', pair.key) }}: ${{ pair.value }}
SETUP_VP_CACHE: ${{ iif(eq(parameters.cache, true), 'true', 'false') }}
SETUP_VP_CACHE_DEPENDENCY_PATH: ${{ parameters.cacheDependencyPath }}
6 changes: 3 additions & 3 deletions dist/azure/index.mjs

Large diffs are not rendered by default.

180 changes: 180 additions & 0 deletions src/azure/auth-env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { describe, expect, it } from "vite-plus/test";
import { applyAuthEnv } from "./auth-env.js";

describe("Azure authEnv", () => {
it("preserves credential values and removes their transport variables", () => {
const secret = "quotes \" ' \\ and\nnewlines $(literal) `literal`";
const env: NodeJS.ProcessEnv = {
SETUP_VP_AUTH_ENV_CUSTOM_TOKEN: secret,
SETUP_VP_AUTH_ENV_NODE_AUTH_TOKEN: "registry-token",
SETUP_VP_AUTH_ENV_NPM_TOKEN: "npm-token",
SETUP_VP_AUTH_ENV_YARN_NPM_AUTH_TOKEN: "yarn-token",
SETUP_VP_AUTH_ENV_REGISTRY_PASSWORD: "password",
SETUP_VP_AUTH_ENV_REGISTRY_SECRET: "secret",
SETUP_VP_AUTH_ENV_REGISTRY_KEY: "key",
SETUP_VP_AUTH_ENV_GITHUB_TOKEN: "github-token",
SETUP_VP_AUTH_ENV_CI_JOB_TOKEN: "gitlab-token",
SETUP_VP_AUTH_ENV_SYSTEM_ACCESSTOKEN: "azure-token",
CUSTOM_TOKEN: "previous-token",
NODE_OPTIONS: "--max-old-space-size=4096",
UNRELATED: "$(leave-me)",
};

applyAuthEnv(env);

expect(env).toEqual({
CUSTOM_TOKEN: secret,
NODE_AUTH_TOKEN: "registry-token",
NPM_TOKEN: "npm-token",
YARN_NPM_AUTH_TOKEN: "yarn-token",
REGISTRY_PASSWORD: "password",
REGISTRY_SECRET: "secret",
REGISTRY_KEY: "key",
GITHUB_TOKEN: "github-token",
CI_JOB_TOKEN: "gitlab-token",
SYSTEM_ACCESSTOKEN: "azure-token",
NODE_OPTIONS: "--max-old-space-size=4096",
UNRELATED: "$(leave-me)",
});
});

it.each(["$(MISSING)", "$(OTHER_SECRET)", undefined])(
"treats an unresolved mapping as missing (%s)",
(value) => {
const env = { SETUP_VP_AUTH_ENV_CUSTOM_TOKEN: value, CUSTOM_TOKEN: "previous-token" };
applyAuthEnv(env);
expect(env).toEqual({});
},
);

it("preserves empty values and the case of custom credential names", () => {
const env = { setup_vp_auth_env_Custom_Token: "", SETUP_VP_AUTH_ENV__TOKEN: "private" };
applyAuthEnv(env);
expect(env).toEqual({ Custom_Token: "", _TOKEN: "private" });
});

it.each([
"NODE_OPTIONS",
"node_options",
"Node_Options",
"NODE_PATH",
"NODE_EXTRA_CA_CERTS",
"NODE_TLS_REJECT_UNAUTHORIZED",
"BASH_ENV",
"BASHOPTS",
"ENV",
"SHELLOPTS",
"SHELL",
"ZDOTDIR",
"LD_PRELOAD",
"LD_LIBRARY_PATH",
"DYLD_INSERT_LIBRARIES",
"OPENSSL_CONF",
"SSL_CERT_FILE",
"PATH",
"Path",
"PATHEXT",
"COMSPEC",
"PSModulePath",
"DOTNET_STARTUP_HOOKS",
"HOME",
"USERPROFILE",
"TMPDIR",
"TEMP",
"TMP",
"SYSTEMROOT",
"NPM_CONFIG_USERCONFIG",
"npm_config_script_shell",
"NPM_EXECPATH",
"PNPM_HOME",
"YARN_RC_FILENAME",
"BUN_OPTIONS",
"COREPACK_HOME",
"VP_HOME",
"SFW_BIN",
"XDG_CONFIG_HOME",
"HTTPS_PROXY",
"SETUP_VP_RUN_INSTALL",
"SETUP_VP_AUTH_ENV_NODE_OPTIONS",
"AGENT_TEMPDIRECTORY",
"SYSTEM_DEFAULTWORKINGDIRECTORY",
"BUILD_SOURCESDIRECTORY",
"PIPELINE_WORKSPACE",
"CI",
"CI_PROJECT_DIR",
"GITHUB_ENV",
"RUNNER_TEMP",
"INPUT_SCRIPT",
"VSTS_TASKVARIABLE_SECRET",
"SETUP_VP_TOKEN",
"NODE_SECRET",
"LD_SECRET",
"AGENT_KEY",
"PYTHONPATH",
"PERL5OPT",
"RUBYOPT",
"GIT_SSH_COMMAND",
"ARBITRARY_SETTING",
"",
"1TOKEN",
"TOKEN-NAME",
"TOKEN=NAME",
"TOKEN\nNAME",
"ſECRET",
])("rejects %j before applying any mappings", (name) => {
const env = {
SETUP_VP_AUTH_ENV_CUSTOM_TOKEN: "secret",
[`SETUP_VP_AUTH_ENV_${name}`]: "sensitive-value",
};
const original = { ...env };

expect(() => applyAuthEnv(env)).toThrow(
new Error(`authEnv variable ${JSON.stringify(name)} is not a supported credential name`),
);
expect(env).toEqual(original);
});

it("rejects case-insensitive duplicate names on every platform", () => {
const env = { SETUP_VP_AUTH_ENV_TOKEN: "one", SETUP_VP_AUTH_ENV_Token: "two" };
expect(() => applyAuthEnv(env)).toThrow("duplicate credential name");
expect(env).toEqual({ SETUP_VP_AUTH_ENV_TOKEN: "one", SETUP_VP_AUTH_ENV_Token: "two" });
});

it.each(["TOKEN", "Token"])(
"rejects original duplicate names after Windows retains only %s",
(name) => {
const env = {
SETUP_VP_AUTH_ENV: JSON.stringify({ TOKEN: "$(FIRST)", Token: "$(SECOND)" }),
[`SETUP_VP_AUTH_ENV_${name}`]: "surviving-secret",
};
const original = { ...env };

expect(() => applyAuthEnv(env)).toThrow('authEnv contains duplicate credential name "Token"');
expect(env).toEqual(original);
},
);

it("uses metadata only for names and removes it before passing credentials onward", () => {
const env = {
SETUP_VP_AUTH_ENV: JSON.stringify({ CUSTOM_TOKEN: "$(MY_SECRET)" }),
SETUP_VP_AUTH_ENV_CUSTOM_TOKEN: 'resolved "secret" with \\ and\nnewlines',
};

applyAuthEnv(env);

expect(env).toEqual({ CUSTOM_TOKEN: 'resolved "secret" with \\ and\nnewlines' });
});

it.each(["sensitive-value", "null", "[]", "42", '"sensitive-value"'])(
"rejects invalid metadata without exposing its contents (%s)",
(value) => {
const env = { SETUP_VP_AUTH_ENV: value, SETUP_VP_AUTH_ENV_CUSTOM_TOKEN: "secret" };
const original = { ...env };

expect(() => applyAuthEnv(env)).toThrow(
new Error("Invalid authEnv metadata: expected a JSON object"),
);
expect(env).toEqual(original);
},
);
});
104 changes: 104 additions & 0 deletions src/azure/auth-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { isReservedAuthVariable } from "../ci/auth.js";
import type { RuntimeEnv } from "../ci/types.js";

const AUTH_ENV_PREFIX = "SETUP_VP_AUTH_ENV_";
const CREDENTIAL_EXCEPTIONS = new Set([
"NODE_AUTH_TOKEN",
"SYSTEM_ACCESSTOKEN",
"YARN_NPM_AUTH_TOKEN",
"YARN_NPM_AUTH_IDENT",
]);
const RESERVED_AUTH_PREFIXES = [
"NODE_",
"BASH",
"LD_",
"DYLD_",
"OPENSSL_",
"SSL_",
"NPM_CONFIG_",
"PNPM_",
"YARN_",
"BUN_",
"COREPACK_",
"VP_",
"VITE_",
"SFW_",
"SOCKET_",
"XDG_",
"DOTNET_",
"COMPLUS_",
"COR_",
"POWERSHELL_",
"PSMODULE",
"INPUT_",
"ENDPOINT_",
"VSTS_",
"AZP_",
"TASK_",
"PIPELINE_",
"AZURE_",
];

function isCredentialName(name: string): boolean {
// These credential names belong to otherwise reserved namespaces.
if (CREDENTIAL_EXCEPTIONS.has(name)) return true;
// Accept credential names, rather than trying to enumerate every environment
// variable that can control a shell, interpreter, or package manager.
return (
/(?:^|_)(?:TOKEN|PASSWORD|SECRET|KEY)$/.test(name) &&
!isReservedAuthVariable(name) &&
!RESERVED_AUTH_PREFIXES.some((prefix) => name.startsWith(prefix))
);
}

function declaredAuthNames(value: string): string[] {
let declaration: unknown;
try {
declaration = JSON.parse(value);
} catch {
// JSON errors can contain secret values. Do not include parser diagnostics.
throw new Error("Invalid authEnv metadata: expected a JSON object");
}
if (!declaration || typeof declaration !== "object" || Array.isArray(declaration)) {
throw new Error("Invalid authEnv metadata: expected a JSON object");
}
return Object.keys(declaration);
}

function validateAuthNames(names: string[]): void {
const seenNames = new Set<string>();
for (const name of names) {
const normalizedName = name.toUpperCase();
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || !isCredentialName(normalizedName)) {
throw new Error(
`authEnv variable ${JSON.stringify(name)} is not a supported credential name`,
);
}
if (seenNames.has(normalizedName)) {
throw new Error(`authEnv contains duplicate credential name ${JSON.stringify(name)}`);
}
seenNames.add(normalizedName);
}
}

/** Decode auth mappings only after the task's shell and Node.js have started. */
export function applyAuthEnv(env: RuntimeEnv): void {
const entries = Object.entries(env)
.filter(([key]) => key.toUpperCase().startsWith(AUTH_ENV_PREFIX))
.map(([key, value]) => ({ key, name: key.slice(AUTH_ENV_PREFIX.length), value }));

// Windows can collapse case variants before Node starts. Validate the original
// names as well as the surviving environment entries before changing anything.
if (env.SETUP_VP_AUTH_ENV !== undefined) {
validateAuthNames(declaredAuthNames(env.SETUP_VP_AUTH_ENV));
}
validateAuthNames(entries.map(({ name }) => name));

delete env.SETUP_VP_AUTH_ENV;
for (const { key, name, value } of entries) {
delete env[key];
// Azure leaves missing secret macros unchanged. Do not pass them as tokens.
if (value === undefined || /^\$\([^)]+\)$/.test(value)) delete env[name];
else env[name] = value;
}
}
2 changes: 2 additions & 0 deletions src/azure/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { getSfwAssetName, isMuslLinux, setupSfw, SFW_VERSION } from "../ci/insta
import { getCommandOutput, run } from "../ci/process.js";
import { parseRunInstall, runInstall } from "../ci/run-install.js";
import { parseInstalledVpVersion } from "../ci/version.js";
import { applyAuthEnv } from "./auth-env.js";
import { logInfo, logWarning, prependPath, setVariable } from "./commands.js";
import { installVitePlus } from "./install-viteplus.js";
import { parseAzureInputs, resolveProjectDirFromInputs } from "./inputs.js";
Expand Down Expand Up @@ -163,6 +164,7 @@ export async function runFinalize(
env: NodeJS.ProcessEnv = process.env,
ports: AzurePorts = defaultPorts,
): Promise<void> {
applyAuthEnv(env);
const inputs = parseAzureInputs(env);
const projectDir = resolveProjectDirFromInputs(inputs);
// Azure leaves undefined macros unexpanded, including aliases supplied via
Expand Down
Loading
Loading