Files
TypeScript/scripts/create-github-app-token.yml

615 lines
24 KiB
YAML

# Step template for creating a GitHub App token via Azure Key Vault.
# This template can be used in release jobs where checkout is not available.
# The CLI script from microsoft/create-github-app-token-via-key-vault is inlined below.
#
# The token is created and stored in an Azure Pipelines variable (default: GH_TOKEN).
# A revocation step runs at the end with condition: always().
# To use the token, add your steps between this template's create and revoke steps
# by setting insertSteps.
parameters:
- name: azureSubscription
type: string
default: 'TypeScript Public CI'
- name: owner
type: string
default: 'microsoft'
- name: repositories
type: string
- name: permissions
type: string
- name: tokenVariable
type: string
default: 'GH_TOKEN'
- name: insertSteps
type: stepList
default: []
steps:
- task: AzureCLI@2
displayName: Create GitHub App token
inputs:
azureSubscription: ${{ parameters.azureSubscription }}
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
cat << 'GITHUB_APP_TOKEN_CLI_EOF' > /tmp/create-github-app-token.cjs
"use strict";
// src/api.ts
var defaultRefreshWindowMs = 5 * 60 * 1e3;
var defaultGitHubApiUrl = "https://api.github.com";
var transientRetryCount = 3;
var GitHubRequestError = class extends Error {
status;
constructor(message, status) {
super(message);
this.status = status;
}
};
function assertValue(value, message) {
if (!value) {
throw new Error(message);
}
return value;
}
function base64url(value) {
return Buffer.from(value).toString("base64url");
}
async function sleep(ms) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
function isRetryableError(error) {
return error instanceof GitHubRequestError ? error.status >= 500 : error instanceof TypeError;
}
async function retryTransient(operation) {
for (let attempt = 0; ; attempt++) {
try {
return await operation();
} catch (error) {
if (attempt >= transientRetryCount || !isRetryableError(error)) {
throw error;
}
await sleep(2 ** attempt * 1e3);
}
}
}
function splitRepositoryNames(repositories) {
if (Array.isArray(repositories)) {
return repositories.map((repo) => `${repo}`.trim()).filter(Boolean);
}
if (typeof repositories === "string") {
return repositories.split(/[,\n]/).map((repo) => repo.trim()).filter(Boolean);
}
return [];
}
function stableObject(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return value;
}
return Object.fromEntries(
Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => [key, stableObject(entry)])
);
}
function githubHeaders(token, json = false) {
return {
"Accept": "application/vnd.github+json",
"Authorization": `Bearer ${token}`,
...json ? { "Content-Type": "application/json" } : {},
"X-GitHub-Api-Version": "2022-11-28"
};
}
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function requiredIntegerProperty(value, property, failureMessage) {
const propertyValue = isRecord(value) ? value[property] : void 0;
if (typeof propertyValue !== "number" || !Number.isInteger(propertyValue)) {
throw new Error(failureMessage);
}
return propertyValue;
}
function requiredStringProperty(value, property, failureMessage) {
const propertyValue = isRecord(value) ? value[property] : void 0;
if (typeof propertyValue !== "string" || !propertyValue) {
throw new Error(failureMessage);
}
return propertyValue;
}
function validatePermissionName(key) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
throw new Error(`Invalid permission name: ${key}`);
}
}
function validatePermissionLevel(key, level) {
if (level !== "read" && level !== "write" && level !== "admin") {
throw new Error(`Invalid permission level for ${key}: ${level}`);
}
return level;
}
function validatePermissions(value) {
if (value === void 0) {
return void 0;
}
if (!isRecord(value)) {
throw new Error("permissions must be an object");
}
const permissions = {};
for (const [key, level] of Object.entries(value)) {
validatePermissionName(key);
permissions[key] = validatePermissionLevel(key, level);
}
return Object.keys(permissions).length === 0 ? void 0 : permissions;
}
async function requestJson(url, init, failureMessage) {
const response = await fetch(url, init);
const body = await response.text();
if (!response.ok) {
throw new GitHubRequestError(
`${failureMessage}: ${response.status} ${response.statusText}: ${body}`,
response.status
);
}
try {
return JSON.parse(body);
} catch {
throw new Error(`${failureMessage}: GitHub returned invalid JSON`);
}
}
async function requestNoContent(url, init, failureMessage) {
const response = await fetch(url, init);
if (!response.ok) {
const body = await response.text();
throw new GitHubRequestError(
`${failureMessage}: ${response.status} ${response.statusText}: ${body}`,
response.status
);
}
}
function parseRepositoryInput(input) {
const parts = input.split("/");
if (parts.length === 1 && parts[0]) {
return { input, name: parts[0] };
}
if (parts.length === 2 && parts[0] && parts[1]) {
return { input, owner: parts[0], name: parts[1] };
}
throw new Error(`Invalid repository '${input}'. Expected 'repository' or 'owner/repository'.`);
}
function normalizeRepositoryTarget(owner, repositories, defaultOwner) {
const parsedRepositories = repositories.map(parseRepositoryInput);
const repositoryOwner = parsedRepositories.find((repository) => repository.owner)?.owner;
const parsedOwner = owner || defaultOwner || repositoryOwner;
if (!parsedOwner) {
throw new Error("owner is required when repositories are provided");
}
const mismatchedRepository = parsedRepositories.find(
(repository) => repository.owner && repository.owner.toLowerCase() !== parsedOwner.toLowerCase()
);
if (mismatchedRepository) {
throw new Error(
`Repository '${mismatchedRepository.input}' includes owner '${mismatchedRepository.owner}', which does not match the resolved owner '${parsedOwner}'.`
);
}
return {
owner: parsedOwner,
repositories: parsedRepositories.map((repository) => repository.name)
};
}
function resolveInstallationTarget(options, defaultOwner) {
const repositories = splitRepositoryNames(options.repositories ?? options.repositoryNames);
if (options.enterprise) {
if (options.owner || repositories.length > 0) {
throw new Error("Cannot use 'enterprise' with 'owner' or 'repositories'");
}
return { type: "enterprise", enterprise: options.enterprise };
}
const owner = assertValue(options.owner ?? defaultOwner, "owner is required to discover installation ID");
if (repositories.length === 0) {
return { type: "owner", owner };
}
return { type: "repository", owner, repositories };
}
function createGitHubAppAuth(options) {
assertValue(options.appClientId, "appClientId is required");
assertValue(options.signer, "signer is required");
const appClientId = options.appClientId;
const signer = options.signer;
const defaultOwner = options.defaultOwner;
const refreshWindowMs = options.refreshWindowMs ?? defaultRefreshWindowMs;
const githubApiUrl = options.githubApiUrl ?? defaultGitHubApiUrl;
const installationCache = /* @__PURE__ */ new Map();
const tokenCache = /* @__PURE__ */ new Map();
async function createJwt() {
const now = Math.floor(Date.now() / 1e3);
const iat = now - 60;
const exp = now + 9 * 60;
const header = base64url(JSON.stringify({ typ: "JWT", alg: "RS256" }));
const payload = base64url(JSON.stringify({ iat, exp, iss: appClientId }));
const signingInput = `${header}.${payload}`;
const signature = await signer(signingInput);
return `${signingInput}.${signature}`;
}
async function discoverInstallation(target) {
const cacheKey = JSON.stringify(target);
const cached = installationCache.get(cacheKey);
if (cached !== void 0) {
return cached;
}
const jwt = await createJwt();
let installation;
switch (target.type) {
case "enterprise":
installation = await requestJson(
`${githubApiUrl}/enterprises/${target.enterprise}/installation`,
{ headers: githubHeaders(jwt) },
"Could not discover GitHub App installation ID"
);
break;
case "owner":
try {
installation = await requestJson(
`${githubApiUrl}/orgs/${target.owner}/installation`,
{ headers: githubHeaders(jwt) },
"Could not discover GitHub App installation ID"
);
} catch (error) {
if (!(error instanceof GitHubRequestError) || error.status !== 404) {
throw error;
}
installation = await requestJson(
`${githubApiUrl}/users/${target.owner}/installation`,
{ headers: githubHeaders(jwt) },
"Could not discover GitHub App installation ID"
);
}
break;
case "repository":
installation = await requestJson(
`${githubApiUrl}/repos/${target.owner}/${assertValue(target.repositories[0], "repository is required")}/installation`,
{ headers: githubHeaders(jwt) },
"Could not discover GitHub App installation ID"
);
break;
}
const result = {
id: requiredIntegerProperty(installation, "id", "GitHub did not return an installation ID"),
appSlug: requiredStringProperty(installation, "app_slug", "GitHub did not return an App slug")
};
installationCache.set(cacheKey, result);
return result;
}
async function getInstallationToken(options2) {
const target = resolveInstallationTarget(options2, defaultOwner);
const permissions = validatePermissions(options2.permissions);
return retryTransient(async () => {
const installation = await discoverInstallation(target);
const repositories = target.type === "repository" ? target.repositories : [];
const cacheKey = JSON.stringify({
installationId: installation.id,
repositories: [...repositories].sort(),
permissions: stableObject(permissions)
});
const cached = tokenCache.get(cacheKey);
if (cached && Date.now() < new Date(cached.expiresAt).getTime() - refreshWindowMs) {
return cached;
}
const jwt = await createJwt();
const body = {
...repositories.length > 0 ? { repositories } : {},
...permissions ? { permissions } : {}
};
const token = await requestJson(
`${githubApiUrl}/app/installations/${installation.id}/access_tokens`,
{
method: "POST",
headers: githubHeaders(jwt, true),
body: JSON.stringify(body)
},
"Could not create GitHub App installation token"
);
const result = {
token: requiredStringProperty(token, "token", "GitHub did not return an installation token"),
expiresAt: requiredStringProperty(
token,
"expires_at",
"GitHub did not return an installation token expiration"
),
installationId: installation.id,
appSlug: installation.appSlug,
repositories,
permissions: isRecord(token) && isRecord(token["permissions"]) ? token["permissions"] : permissions ?? {}
};
tokenCache.set(cacheKey, result);
return result;
});
}
async function getToken(options2) {
return (await getInstallationToken(options2)).token;
}
async function revokeToken(token) {
await requestNoContent(
`${githubApiUrl}/installation/token`,
{
method: "DELETE",
headers: githubHeaders(token)
},
"Could not revoke GitHub App installation token"
);
}
return {
getInstallationToken,
getToken,
revokeToken
};
}
// src/azureCliSigner.ts
var import_node_child_process = require("node:child_process");
var import_node_crypto = require("node:crypto");
var cachedAzCommand;
function isRecord2(value) {
return typeof value === "object" && value !== null;
}
function base64ToBase64url(value) {
return value.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function commandExists(command) {
try {
(0, import_node_child_process.execFileSync)("where.exe", [command], {
encoding: "utf8",
stdio: ["ignore", "ignore", "ignore"]
});
return true;
} catch {
return false;
}
}
function azCommand() {
if (cachedAzCommand) {
return cachedAzCommand;
}
if (process.platform !== "win32") {
cachedAzCommand = { command: "az", argsPrefix: [] };
return cachedAzCommand;
}
for (const command of ["az.exe", "az.cmd", "az.bat", "az"]) {
if (commandExists(command)) {
cachedAzCommand = command.endsWith(".exe") ? { command, argsPrefix: [] } : { command: process.env["ComSpec"] || "cmd.exe", argsPrefix: ["/d", "/s", "/c", command] };
return cachedAzCommand;
}
}
throw new Error("Azure CLI (`az`) was not found on PATH");
}
function signDigest(keyId, digest) {
const { command, argsPrefix } = azCommand();
try {
return (0, import_node_child_process.execFileSync)(command, [
...argsPrefix,
"keyvault",
"key",
"sign",
"--id",
keyId,
"--algorithm",
"RS256",
"--digest",
digest,
"--query",
"signature",
"--output",
"tsv",
"--only-show-errors"
], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"]
}).trim();
} catch (error) {
if (isRecord2(error)) {
if (error["code"] === "ENOENT") {
throw new Error("Azure CLI (`az`) was not found on PATH");
}
const errorStderr = error["stderr"];
const stderr = typeof errorStderr === "string" ? errorStderr.trim() : "";
if (typeof error["status"] === "number") {
throw new Error(
`Azure Key Vault signing failed with exit code ${error["status"]}${stderr ? `: ${stderr}` : ""}`
);
}
}
throw new Error("Azure Key Vault signing failed");
}
}
function createAzureCliKeyVaultSigner(keyId) {
if (!keyId) {
throw new Error("keyId is required");
}
return async (signingInput) => {
const digest = (0, import_node_crypto.createHash)("sha256").update(signingInput).digest("base64");
const signature = signDigest(keyId, digest);
if (!signature) {
throw new Error("Azure Key Vault did not return a signature");
}
return base64ToBase64url(signature);
};
}
// src/proxy.ts
var proxyEnvironmentKeys = [
"https_proxy",
"HTTPS_PROXY",
"http_proxy",
"HTTP_PROXY"
];
function proxyEnvironmentConfigured() {
return proxyEnvironmentKeys.some((key) => process.env[key]);
}
function nativeProxySupportEnabled() {
return process.env["NODE_USE_ENV_PROXY"] === "1";
}
function ensureNativeProxySupport() {
if (!proxyEnvironmentConfigured() || nativeProxySupportEnabled()) {
return;
}
throw new Error(
"A proxy environment variable is set, but Node.js native proxy support is not enabled. Set NODE_USE_ENV_PROXY=1 before running this tool."
);
}
// src/cli.ts
function requiredEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`${name} must be set`);
}
return value;
}
function getAppClientId() {
const appClientId = process.env["APP_CLIENT_ID"];
if (!appClientId) {
throw new Error("APP_CLIENT_ID must be set");
}
return appClientId;
}
function validatePermissionName2(key) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
throw new Error(`Invalid permission name: ${key}`);
}
}
function validatePermissionLevel2(key, level) {
if (level !== "read" && level !== "write" && level !== "admin") {
throw new Error(`Invalid permission level for ${key}: ${level}`);
}
return level;
}
function parsePermissions(value) {
if (!value) {
return void 0;
}
const permissions = {};
for (const entry of splitRepositoryNames(value)) {
const parts = entry.split(":");
if (parts.length !== 2) {
throw new Error(`Permission entry must include an explicit level: ${entry}`);
}
const key = parts[0]?.trim();
const rawLevel = parts[1]?.trim();
if (!key) {
throw new Error(`Permission entry must include a permission name: ${entry}`);
}
validatePermissionName2(key);
if (Object.hasOwn(permissions, key)) {
throw new Error(`Duplicate permission: ${key}`);
}
permissions[key] = validatePermissionLevel2(key, rawLevel);
}
return Object.keys(permissions).length === 0 ? void 0 : permissions;
}
function validateVariableName(name, envName) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
throw new Error(`${envName} must be an environment-style variable name`);
}
}
function parseOutputMode(value) {
const output = (value || "stdout").trim().toLowerCase();
if (output === "azure" || output === "azure-pipelines" || output === "stdout") {
return output;
}
throw new Error('OUTPUT must be "azure", "azure-pipelines", or "stdout"');
}
function getTokenOptions() {
const enterprise = process.env["ENTERPRISE"];
const owner = process.env["OWNER"];
const repositories = splitRepositoryNames(process.env["REPOSITORIES"]);
const permissions = parsePermissions(process.env["PERMISSIONS"]);
if (enterprise) {
if (owner || repositories.length > 0) {
throw new Error("Cannot use ENTERPRISE with OWNER or REPOSITORIES");
}
return { enterprise, permissions };
}
if (repositories.length > 0) {
return { ...normalizeRepositoryTarget(owner, repositories, void 0), permissions };
}
if (owner) {
return { owner, permissions };
}
throw new Error("OWNER, REPOSITORIES, or ENTERPRISE must be set");
}
function writeAzurePipelinesOutput(installationToken) {
const variableName = requiredEnv("AZURE_TOKEN_VARIABLE");
validateVariableName(variableName, "AZURE_TOKEN_VARIABLE");
process.stdout.write(`##vso[task.setvariable variable=${variableName};isSecret=true]${installationToken.token}
`);
}
function writeOutput(installationToken, output) {
switch (output) {
case "azure":
case "azure-pipelines":
writeAzurePipelinesOutput(installationToken);
break;
case "stdout":
process.stdout.write(`${installationToken.token}
`);
break;
}
}
function reportError(error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`error: ${message}`);
}
async function main() {
ensureNativeProxySupport();
const githubApiUrl = process.env["GITHUB_API_URL"] || "https://api.github.com";
const revokeTokenValue = process.env["REVOKE_TOKEN"];
if (revokeTokenValue) {
const response = await fetch(`${githubApiUrl}/installation/token`, {
method: "DELETE",
headers: {
"Accept": "application/vnd.github+json",
"Authorization": `Bearer ${revokeTokenValue}`,
"X-GitHub-Api-Version": "2022-11-28"
}
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Could not revoke token: ${response.status} ${response.statusText}: ${body}`);
}
return;
}
const githubAuth = createGitHubAppAuth({
appClientId: getAppClientId(),
signer: createAzureCliKeyVaultSigner(requiredEnv("KEY_ID")),
githubApiUrl
});
const installationToken = await githubAuth.getInstallationToken(getTokenOptions());
writeOutput(installationToken, parseOutputMode(process.env["OUTPUT"]));
}
void main().catch((error) => {
reportError(error);
process.exitCode = 1;
});
GITHUB_APP_TOKEN_CLI_EOF
node /tmp/create-github-app-token.cjs
env:
APP_CLIENT_ID: $(TYPESCRIPT_AUTOMATION_GITHUB_APP_CLIENT_ID)
KEY_ID: $(TYPESCRIPT_AUTOMATION_GITHUB_APP_KEY_ID)
OWNER: ${{ parameters.owner }}
REPOSITORIES: ${{ parameters.repositories }}
PERMISSIONS: ${{ parameters.permissions }}
OUTPUT: azure-pipelines
AZURE_TOKEN_VARIABLE: ${{ parameters.tokenVariable }}
- ${{ each step in parameters.insertSteps }}:
- ${{ step }}
- task: Bash@3
displayName: Revoke GitHub App token
condition: always()
continueOnError: true
inputs:
targetType: inline
script: node /tmp/create-github-app-token.cjs
env:
REVOKE_TOKEN: $(${{ parameters.tokenVariable }})