UNPKG

@dataroadinc/setup-auth

Version:

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

577 lines (530 loc) 20.7 kB
import fs from "fs/promises" // Needed for file operations import path from "path" // Needed for path operations import { gcpCallAPI } from "../../../providers/gcp/api-call.js" // Import the Brand client 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" // For retry options // Import constants from central file 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" // Org policy manager 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 sleep and waitForIamPropagation utilities import { OrganizationsClient } from "@google-cloud/resource-manager" // Needed for Org Policy Manager import { ServiceUsageClient } from "@google-cloud/service-usage" // Import ServiceUsageClient import axios from "axios" import { backOff } from "exponential-backoff" // For potential retry import { GcpSetupServiceAccountOptions } from "./options.js" interface GcpIamBinding { role: string members: string[] } interface GcpIamPolicy { bindings: GcpIamBinding[] etag?: string } interface GcpServiceAccountKeyResponse { privateKeyData?: string } interface GcpServiceAccountResponse { email: string name: string displayName: string description: string } interface GcpServiceAccountKeysResponse { keys?: Array<{ name: string }> } /** * Setup a GCP service account that can be used to execute the gcp-setup-oauth command. * * NOTE: This is the entry point for the gcp-setup-service-account command * * @param options Options for setting up the GCP service account * @returns Result of the setup operation */ export async function gcpSetupServiceAccount( options: GcpSetupServiceAccountOptions ): Promise<void> { await _gcpSetupServiceAccount(options) console.log("✅ Service account setup completed successfully.") } /** * Set up a GCP service account. * * @param options Options for setting up the service account */ async function _gcpSetupServiceAccount( options: GcpSetupServiceAccountOptions ): Promise<void> { console.log("Setting up GCP service account...") // Print both gcloud and ADC accounts for clarity await printGcloudAndAdcAccounts() // --- Automated ADC authentication if needed --- 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)." ) } // First try to get ADC email let adcEmail = await getAdcEmailOrNull() // If no ADC, attempt automatic authentication if (!adcEmail) { console.log("ADC not configured. Attempting automatic authentication...") const cli = new GcpCloudCliClient() try { await cli.autoApplicationDefaultAuthenticate() // Try again after authentication 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 } ) } } // Verify we now have ADC email if (!adcEmail) { throw new SetupAuthError( "Could not determine Application Default Credentials (ADC) email after authentication attempt." ) } // Check domain matches 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.` ) } // --- End automated ADC authentication --- // --- Step 1 --- console.log( "\n--- Step 1: Validate authentication & ensure prerequisite APIs ---" ) const identity = GcpIdentityFactory.createAdcIdentity() let userEmail: string | undefined 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, }) } // --- Step 1A: Ensure project exists (create if missing) --- 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) // --- End Step 1A --- // --- Step 1B: Ensure current user has required IAM roles for API enablement --- console.log( "\n--- Step 1B: Ensure current user has required IAM roles for API enablement ---" ) const projectIamManager = await projectManager.getIamManager(projectId) await projectIamManager.ensurePermissions() // This will grant roles like serviceusage.serviceUsageAdmin if missing // (No unconditional wait here; IAM manager handles propagation waits only if needed) // --- End Step 1B --- // --- Ensure Org Policy API is enabled before using its manager --- console.log( "Ensuring prerequisite API 'orgpolicy.googleapis.com' is enabled..." ) const auth = await identity.getGaxAuthClient() const serviceUsageClient = new ServiceUsageClient({ auth }) const projectResourceName = `projects/${projectId}` // List of APIs to ensure are enabled const prerequisiteApis = [ REQUIRED_SERVICES.ORG_POLICY, // Needed for Org Policy checks REQUIRED_SERVICES.IAP, // Needed for Brand management ] 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 } ) } } } // --- End Prerequisite API Check --- // --- Step 1.5 --- console.log("\n--- Step 1.5: Early Org Policy Check ---") const organizationsClient = new OrganizationsClient({ auth }) // Reuse auth const orgPolicyManager = new GcpOrgPolicyManager( identity, orgId, organizationsClient ) await orgPolicyManager.initialize() // TODO: Move to a more general place and merge with other declarations of PUBLIC_SERVICES in the codebase 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.") // --- End Early Org Policy Check --- // --- Step 2 --- console.log("\n--- Step 2: Ensure Organization IAM Permissions ---") const organizationManager = new GcpOrganizationManager(identity, orgId) await organizationManager.ensurePermissions() // Initialize project manager // (projectManager already initialized above) // --- Step 3 --- // (project creation already handled above) // Create project IAM manager // (projectIamManager already initialized above) // --- Step 4 --- console.log("\n--- Step 4: Ensure project permissions & enable services ---") // Org Policy check is done, so this focuses on IAM and enabling APIs await projectIamManager.ensurePermissions() // --- Step 4.5: Create/Ensure OAuth Brand (Consent Screen) --- > NEW STEP 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: string 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}`) // Save the Brand Resource Name to env 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) // Make this error fatal - no fallback throw new SetupAuthError( "Failed to ensure OAuth Brand exists. Cannot proceed.", { cause: brandError } ) } // --- End Step 4.5 --- // --- Step 5 --- 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, }, } )) as GcpServiceAccountResponse console.log(`✅ Service account created: ${response.email}`) } catch (error) { let isAlreadyExistsError = false // Check if it's our SetupAuthError containing an Axios error with the specific 409 status if ( error instanceof SetupAuthError && error.originalError && axios.isAxiosError(error.originalError) ) { const axiosError = error.originalError interface ErrorBody { error?: { status?: string } } const responseData = axiosError.response?.data as ErrorBody | undefined if ( axiosError.response?.status === 409 && responseData?.error?.status === "ALREADY_EXISTS" ) { isAlreadyExistsError = true } } else if ( error instanceof Error && error.message?.includes("ALREADY_EXISTS") ) { // Catch cases where the raw error might indicate existence // Be cautious here, might need refinement based on actual error types isAlreadyExistsError = true } if (isAlreadyExistsError) { // Log the user-friendly info message console.log(`ℹ️ Service account '${serviceAccountId}' already exists.`) } else { // Log and re-throw unexpected errors const errorMessage = error instanceof Error ? error.message : String(error) console.error( `Failed to create service account (re-throwing): ${errorMessage}` ) throw error // Re-throw unexpected errors } } // --- Step 6 --- console.log("\n--- Step 6: Assign roles to service account ---") await assignRolesToServiceAccount( options.gcpOauthProjectId!, serviceAccountEmail ) // --- Step 7: Ensure Single Service Account Key --- > REFACTORED 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 { // 1. List existing user-managed keys console.log("Listing existing user-managed keys...") let existingKeys: { name: string }[] = [] try { const listResponse = (await gcpCallAPI( `https://iam.googleapis.com/v1/${keyResourcePathBase}/keys?keyTypes=USER_MANAGED`, "GET" )) as GcpServiceAccountKeysResponse existingKeys = listResponse?.keys || [] // Assuming the response has a 'keys' array console.log(`Found ${existingKeys.length} existing user-managed key(s).`) } catch (listError) { // If listing fails (e.g., 404 if no keys?), treat as no keys found, but log warning 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 ) } } // 2. Delete existing user-managed keys 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 { // Use the full key name (resource path) for deletion await gcpCallAPI( `https://iam.googleapis.com/v1/${key.name}`, "DELETE" ) console.log(`Deleted key: ${keyId}`) } catch (deleteError) { // Log warning but continue trying to delete others and create new console.warn(`Warning: Failed to delete key ${keyId}.`, deleteError) } } console.log("Finished deleting existing keys.") // Add a small delay after deleting keys await sleep(2000) } // 3. Create a new key 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", } )) as GcpServiceAccountKeyResponse 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" ) // 4. Save key file and update env 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 } ) } // --- Final Wait --- // Wait for IAM propagation: try to list keys for the service account, retry if permission denied await waitForIamPropagation( async () => { try { // Try listing keys; if it works, propagation is done await gcpCallAPI( `https://iam.googleapis.com/v1/${keyResourcePathBase}/keys?keyTypes=USER_MANAGED`, "GET" ) return true } catch (err: unknown) { // If error is permission denied, return false to retry; otherwise, rethrow 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", } ) } // Updated to use imported PROJECT_ROLES async function assignRolesToServiceAccount( gcpOauthProjectId: string, serviceAccountEmail: string ): Promise<void> { // Grant Project Owner instead of Editor 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}...` ) // TODO: Why not use the google SDK libaries to get the current policy? And/or use the GcpProjectIamManager class? const currentPolicyResponse = (await gcpCallAPI( `https://cloudresourcemanager.googleapis.com/v1/projects/${gcpOauthProjectId}:getIamPolicy`, "POST" )) as GcpIamPolicy const currentPolicy: GcpIamPolicy = 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 } ) } }