UNPKG

@dataroadinc/setup-auth

Version:

CLI tool and programmatic API for automated OAuth setup across cloud platforms

318 lines (317 loc) 16.6 kB
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 }); } }