@dataroadinc/setup-auth
Version:
CLI tool and programmatic API for automated OAuth setup across cloud platforms
318 lines (317 loc) • 16.6 kB
JavaScript
import fs from "fs/promises";
import path from "path";
import { gcpCallAPI } from "../../../providers/gcp/api-call.js";
import { GcpOAuthBrandClient } from "../../../providers/gcp/brand.js";
import { GcpCloudCliClient } from "../../../providers/gcp/cloud-cli-client.js";
import { GcpIdentityFactory } from "../../../providers/gcp/creds/identity.js";
import { BACKOFF_OPTIONS } from "../../../providers/gcp/iam/base-iam.js";
import { PROJECT_ROLES, REQUIRED_SERVICES, } from "../../../providers/gcp/iam/constants.js";
import { GcpOrganizationManager } from "../../../providers/gcp/organization.js";
import { GcpOrgPolicyManager } from "../../../providers/gcp/orgpolicy/index.js";
import { GcpProjectManager } from "../../../providers/gcp/project/index.js";
import { enforceUserDomainOrFail, GCP_OAUTH_APPLICATION_CREDENTIALS, GCP_OAUTH_BRAND_RESOURCE_NAME, getAdcEmailOrNull, printGcloudAndAdcAccounts, updateOrAddEnvVariable, } from "../../../utils/env-handler.js";
import { SetupAuthError } from "../../../utils/error.js";
import { sleep, waitForIamPropagation } from "../../../utils/sleep.js";
import { OrganizationsClient } from "@google-cloud/resource-manager";
import { ServiceUsageClient } from "@google-cloud/service-usage";
import axios from "axios";
import { backOff } from "exponential-backoff";
export async function gcpSetupServiceAccount(options) {
await _gcpSetupServiceAccount(options);
console.log("✅ Service account setup completed successfully.");
}
async function _gcpSetupServiceAccount(options) {
console.log("Setting up GCP service account...");
await printGcloudAndAdcAccounts();
const expectedDomain = process.env.EKG_ORG_PRIMARY_DOMAIN;
if (!expectedDomain) {
throw new SetupAuthError("Missing required environment variable: EKG_ORG_PRIMARY_DOMAIN. This tool enforces a fail-fast approach and requires this variable to be set in your .env.local (e.g., EKG_ORG_PRIMARY_DOMAIN=your-domain.com).");
}
let adcEmail = await getAdcEmailOrNull();
if (!adcEmail) {
console.log("ADC not configured. Attempting automatic authentication...");
const cli = new GcpCloudCliClient();
try {
await cli.autoApplicationDefaultAuthenticate();
adcEmail = await getAdcEmailOrNull();
}
catch (error) {
throw new SetupAuthError("Failed to automatically configure Application Default Credentials.\n" +
"This may be because:\n" +
"1. You're running in a non-interactive environment (CI/CD)\n" +
"2. gcloud is not installed or not in PATH\n" +
"3. You need to manually run: gcloud auth application-default login\n", { cause: error });
}
}
if (!adcEmail) {
throw new SetupAuthError("Could not determine Application Default Credentials (ADC) email after authentication attempt.");
}
const adcDomain = adcEmail.split("@")[1] || "";
if (adcDomain.toLowerCase() !== expectedDomain.toLowerCase()) {
throw new SetupAuthError(`Application Default Credentials (ADC) are for '${adcEmail}', which does not match the required organization domain (${expectedDomain}).\n` +
`Please run 'gcloud auth application-default login' and select your <user>@${expectedDomain} account.`);
}
console.log("\n--- Step 1: Validate authentication & ensure prerequisite APIs ---");
const identity = GcpIdentityFactory.createAdcIdentity();
let userEmail;
try {
console.log("Attempting to validate identity by fetching email...");
userEmail = await identity.getCurrentUserEmail();
enforceUserDomainOrFail(userEmail);
console.log(`Identity validated successfully for user: ${userEmail}`);
}
catch (error) {
console.error("Failed to validate identity:", error);
throw new SetupAuthError("Identity validation failed. Cannot proceed.", {
cause: error,
});
}
console.log("\n--- Step 1A: Get or create the project ---");
const orgId = options.gcpOauthOrganizationId;
const projectId = options.gcpOauthProjectId;
const projectManager = new GcpProjectManager(identity, orgId);
await projectManager.initialize();
await projectManager.createProject(projectId);
console.log("\n--- Step 1B: Ensure current user has required IAM roles for API enablement ---");
const projectIamManager = await projectManager.getIamManager(projectId);
await projectIamManager.ensurePermissions();
console.log("Ensuring prerequisite API 'orgpolicy.googleapis.com' is enabled...");
const auth = await identity.getGaxAuthClient();
const serviceUsageClient = new ServiceUsageClient({ auth });
const projectResourceName = `projects/${projectId}`;
const prerequisiteApis = [
REQUIRED_SERVICES.ORG_POLICY,
REQUIRED_SERVICES.IAP,
];
for (const apiName of prerequisiteApis) {
console.log(`Ensuring prerequisite API '${apiName}' is enabled...`);
const apiResourceName = `${projectResourceName}/services/${apiName}`;
try {
await backOff(() => serviceUsageClient.enableService({ name: apiResourceName }), BACKOFF_OPTIONS);
console.log(`Service ${apiName} enabled or already enabled.`);
await sleep(1000);
}
catch (error) {
if (error instanceof Error && error.message.includes("already enabled")) {
console.log(`Service ${apiName} is already enabled.`);
}
else {
console.error(`Failed to enable prerequisite service ${apiName}:`, error);
throw new SetupAuthError(`Could not enable the required API (${apiName}) needed for setup. Please ensure the user ${userEmail} has 'serviceusage.services.enable' permission on project ${projectId}.`, { cause: error });
}
}
}
console.log("\n--- Step 1.5: Early Org Policy Check ---");
const organizationsClient = new OrganizationsClient({ auth });
const orgPolicyManager = new GcpOrgPolicyManager(identity, orgId, organizationsClient);
await orgPolicyManager.initialize();
const scriptCriticalServices = [
REQUIRED_SERVICES.RESOURCE_MANAGER,
REQUIRED_SERVICES.SERVICE_USAGE,
REQUIRED_SERVICES.IAM,
REQUIRED_SERVICES.CREDENTIALS,
REQUIRED_SERVICES.ORG_POLICY,
];
for (const serviceName of scriptCriticalServices) {
console.log(`Ensuring service '${serviceName}' is allowed by Org Policy...`);
await orgPolicyManager.ensureServiceAllowedAtOrgLevel(serviceName);
}
console.log("✅ Organization Policy check passed for critical services.");
console.log("\n--- Step 2: Ensure Organization IAM Permissions ---");
const organizationManager = new GcpOrganizationManager(identity, orgId);
await organizationManager.ensurePermissions();
console.log("\n--- Step 4: Ensure project permissions & enable services ---");
await projectIamManager.ensurePermissions();
console.log("\n--- Step 4.5: Ensure OAuth Brand (Consent Screen) exists ---");
const appTitle = process.env.EKG_PROJECT_LONG;
if (!appTitle) {
throw new SetupAuthError("Brand application title not found in env var EKG_PROJECT_LONG.");
}
let brandResourceName;
try {
const brandClient = new GcpOAuthBrandClient(options.gcpOauthProjectId, identity);
await brandClient.initialize();
brandResourceName = await brandClient.createOrGetBrand(appTitle);
if (!brandResourceName || !brandResourceName.includes("/brands/")) {
throw new SetupAuthError(`Failed to create or retrieve a valid brand resource name. Received: ${brandResourceName}`);
}
console.log(`✅ Ensured OAuth Brand exists: ${brandResourceName}`);
await updateOrAddEnvVariable(GCP_OAUTH_BRAND_RESOURCE_NAME, brandResourceName);
console.log(`✅ Saved Brand resource name to .env.local as ${GCP_OAUTH_BRAND_RESOURCE_NAME}`);
}
catch (brandError) {
console.error("❌ Failed to create or get OAuth Brand:", brandError);
throw new SetupAuthError("Failed to ensure OAuth Brand exists. Cannot proceed.", { cause: brandError });
}
console.log("\n--- Step 5: Create the service account ---");
const serviceAccountId = "oauth-redirect-updater";
const serviceAccountName = "OAuth Redirect URI Updater";
const serviceAccountDescription = "Updates OAuth redirect URIs for Vercel deployments";
const serviceAccountEmail = `${serviceAccountId}@${options.gcpOauthProjectId}.iam.gserviceaccount.com`;
try {
const response = (await gcpCallAPI(`https://iam.googleapis.com/v1/projects/${options.gcpOauthProjectId}/serviceAccounts`, "POST", {
accountId: serviceAccountId,
serviceAccount: {
displayName: serviceAccountName,
description: serviceAccountDescription,
},
}));
console.log(`✅ Service account created: ${response.email}`);
}
catch (error) {
let isAlreadyExistsError = false;
if (error instanceof SetupAuthError &&
error.originalError &&
axios.isAxiosError(error.originalError)) {
const axiosError = error.originalError;
const responseData = axiosError.response?.data;
if (axiosError.response?.status === 409 &&
responseData?.error?.status === "ALREADY_EXISTS") {
isAlreadyExistsError = true;
}
}
else if (error instanceof Error &&
error.message?.includes("ALREADY_EXISTS")) {
isAlreadyExistsError = true;
}
if (isAlreadyExistsError) {
console.log(`ℹ️ Service account '${serviceAccountId}' already exists.`);
}
else {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Failed to create service account (re-throwing): ${errorMessage}`);
throw error;
}
}
console.log("\n--- Step 6: Assign roles to service account ---");
await assignRolesToServiceAccount(options.gcpOauthProjectId, serviceAccountEmail);
console.log(`\n--- Step 7: Ensure single key file for service account ${serviceAccountEmail}... ---`);
const keyDir = path.resolve(process.cwd(), ".gcp");
const keyPath = path.join(keyDir, "oauth-redirect-updater-key.json");
const keyResourcePathBase = `projects/${options.gcpOauthProjectId}/serviceAccounts/${serviceAccountEmail}`;
try {
console.log("Listing existing user-managed keys...");
let existingKeys = [];
try {
const listResponse = (await gcpCallAPI(`https://iam.googleapis.com/v1/${keyResourcePathBase}/keys?keyTypes=USER_MANAGED`, "GET"));
existingKeys = listResponse?.keys || [];
console.log(`Found ${existingKeys.length} existing user-managed key(s).`);
}
catch (listError) {
if (listError instanceof SetupAuthError &&
listError.originalError &&
axios.isAxiosError(listError.originalError) &&
listError.originalError.response?.status === 404) {
console.log("No existing user-managed keys found (API returned 404).");
existingKeys = [];
}
else {
console.warn("Warning: Failed to list existing keys, proceeding with creation attempt anyway.", listError);
}
}
if (existingKeys.length > 0) {
console.log("Deleting existing user-managed keys...");
for (const key of existingKeys) {
const keyId = key.name.split("/").pop();
console.log(`Deleting key: ${keyId}...`);
try {
await gcpCallAPI(`https://iam.googleapis.com/v1/${key.name}`, "DELETE");
console.log(`Deleted key: ${keyId}`);
}
catch (deleteError) {
console.warn(`Warning: Failed to delete key ${keyId}.`, deleteError);
}
}
console.log("Finished deleting existing keys.");
await sleep(2000);
}
console.log("Creating new service account key...");
const keyResponse = (await gcpCallAPI(`https://iam.googleapis.com/v1/${keyResourcePathBase}/keys`, "POST", {
privateKeyType: "TYPE_GOOGLE_CREDENTIALS_FILE",
keyAlgorithm: "KEY_ALG_RSA_2048",
}));
if (!keyResponse?.privateKeyData) {
throw new SetupAuthError("Failed to retrieve private key data from new key creation response.");
}
const keyData = Buffer.from(keyResponse.privateKeyData, "base64").toString("utf-8");
await fs.mkdir(keyDir, { recursive: true });
await fs.writeFile(keyPath, keyData);
console.log(`✅ New key file created successfully at ${keyPath}`);
await updateOrAddEnvVariable(GCP_OAUTH_APPLICATION_CREDENTIALS, keyPath);
console.log(`✅ Service account key path saved to .env.local as ${GCP_OAUTH_APPLICATION_CREDENTIALS}`);
}
catch (keyError) {
console.error(`❌ Error during Step 7 (Ensure Single Key):`, keyError);
throw new SetupAuthError("Failed during service account key management. Cannot proceed.", { cause: keyError });
}
await waitForIamPropagation(async () => {
try {
await gcpCallAPI(`https://iam.googleapis.com/v1/${keyResourcePathBase}/keys?keyTypes=USER_MANAGED`, "GET");
return true;
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("PERMISSION_DENIED") ||
msg.includes("not authorized")) {
return false;
}
throw err;
}
}, {
timeoutMs: 30000,
intervalMs: 2000,
description: "service account IAM propagation",
});
}
async function assignRolesToServiceAccount(gcpOauthProjectId, serviceAccountEmail) {
const requiredRoles = [
PROJECT_ROLES.OWNER,
PROJECT_ROLES.LOGGING_WRITER,
PROJECT_ROLES.SERVICE_USAGE_ADMIN,
PROJECT_ROLES.SERVICE_USAGE_CONSUMER,
PROJECT_ROLES.IAP_SETTINGS_ADMIN,
"roles/iap.admin",
PROJECT_ROLES.SERVICE_MANAGEMENT_ADMIN,
];
const serviceAccountMember = `serviceAccount:${serviceAccountEmail}`;
let policyModified = false;
try {
console.log(`Fetching current IAM policy for project ${gcpOauthProjectId}...`);
const currentPolicyResponse = (await gcpCallAPI(`https://cloudresourcemanager.googleapis.com/v1/projects/${gcpOauthProjectId}:getIamPolicy`, "POST"));
const currentPolicy = currentPolicyResponse || {
bindings: [],
};
currentPolicy.bindings = currentPolicy.bindings || [];
for (const role of requiredRoles) {
let binding = currentPolicy.bindings.find(b => b.role === role);
if (binding) {
binding.members = binding.members || [];
if (!binding.members.includes(serviceAccountMember)) {
console.log(`Adding ${serviceAccountMember} to existing role ${role}...`);
binding.members.push(serviceAccountMember);
policyModified = true;
}
}
else {
console.log(`Creating new binding for role ${role} with member ${serviceAccountMember}...`);
binding = { role, members: [serviceAccountMember] };
currentPolicy.bindings.push(binding);
policyModified = true;
}
}
if (policyModified) {
console.log(`Setting updated IAM policy for project ${gcpOauthProjectId}...`);
await gcpCallAPI(`https://cloudresourcemanager.googleapis.com/v1/projects/${gcpOauthProjectId}:setIamPolicy`, "POST", { policy: currentPolicy });
console.log(`✅ Successfully updated IAM policy.`);
}
else {
console.log("ℹ️ All required roles already assigned to service account. No policy update needed.");
}
}
catch (error) {
if (error instanceof Error) {
throw new SetupAuthError(`Failed to assign roles to service account: ${error.message}`, { cause: error });
}
throw new SetupAuthError("Failed to assign roles to service account: Unknown error", { cause: error });
}
}