@dataroadinc/setup-auth
Version:
CLI tool and programmatic API for automated OAuth setup across cloud platforms
398 lines (356 loc) • 14.9 kB
text/typescript
// Import constants from the central IAM constants file
import { protos, ServiceUsageClient } from "@google-cloud/service-usage"
import { PlatformType } from "../../../types/index.js"
import { SetupAuthError } from "../../../utils/error.js"
import { getVercelClient } from "../../../utils/vercel/index.js"
import { GcpCloudCliClient } from "../../gcp/cloud-cli-client.js"
import { GcpAuthenticatedIdentity } from "../../gcp/creds/identity.js"
import { GcpOAuthBrandClient, GcpProjectManager } from "../../index.js"
import { PUBLIC_SERVICES, REQUIRED_SERVICES } from "../iam/constants.js"
import { GcpOAuthWebClientManager } from "./client.js"
/**
* This class orchestrates project-level OAuth setup and configuration in GCP.
*
* It serves as the main coordinator in the OAuth client management system, working with:
* - GcpOAuthBrandClient (consent screen management)
* - GcpOAuthClientClient (client credential management)
* - GcpIamManager (permissions)
* - GcpProjectManager (project management)
* - VercelClient (Vercel deployment management)
*
* Key responsibilities:
* - Orchestrating the complete OAuth setup process
* - Managing OAuth client creation and configuration
* - Handling redirect URI updates
* - Integrating with deployment platforms (e.g., Vercel)
* - Providing webhook support for automated updates
*
* The class coordinates between different components to provide a seamless
* OAuth setup experience, handling everything from initial setup to ongoing
* management of OAuth configurations.
*/
export class GcpProjectOAuthSetup {
private initialized: boolean
private readonly identity: GcpAuthenticatedIdentity
private readonly projectId: string
private readonly quotaProjectId: string
private readonly oauthBrandName: string
private readonly platform: PlatformType
private readonly vercelProjectName?: string
private readonly defaultRedirectUri: string
private readonly oauthClient: GcpOAuthWebClientManager
private readonly projectManager: GcpProjectManager
private serviceUsageClient: ServiceUsageClient | undefined
private readonly brandClient: GcpOAuthBrandClient
private clientId: string | undefined
private clientSecret: string | undefined
private allowedDomains?: string
constructor(
identity: GcpAuthenticatedIdentity,
organizationId: string,
projectId: string,
quotaProjectId: string,
oauthBrandName: string,
platform: PlatformType,
clientId?: string,
vercelProjectName?: string,
allowedDomains?: string
) {
console.log(
`DEBUG: GcpProjectOAuthSetup constructor received oauthBrandName: '${oauthBrandName}'`
)
this.initialized = false
this.identity = identity
this.brandClient = new GcpOAuthBrandClient(projectId, identity)
this.projectId = projectId
this.quotaProjectId = quotaProjectId
this.oauthBrandName = oauthBrandName
this.platform = platform
this.vercelProjectName = vercelProjectName
this.defaultRedirectUri = this.getDefaultRedirectUri()
this.clientId = clientId
this.allowedDomains = allowedDomains
this.oauthClient = new GcpOAuthWebClientManager(this.projectId)
this.projectManager = new GcpProjectManager(identity, organizationId)
}
async initialize(): Promise<void> {
if (this.initialized) {
return
}
await this.projectManager.initialize()
await this.brandClient.initialize()
if (!this.serviceUsageClient) {
const authInstance = await this.identity.getAuthClient()
this.serviceUsageClient = new ServiceUsageClient({
auth: authInstance,
quotaProjectId: this.quotaProjectId,
})
}
this.initialized = true
}
/**
* Set up OAuth for a project, this is the main method to call to get the whole process done.
*/
async setupForProject(): Promise<void> {
// --- GCP CLI Automation: Ensure all prerequisites are met automatically ---
const cli = new GcpCloudCliClient()
try {
await cli.checkInstalled()
await cli.checkAlphaComponent()
await cli.autoAuthenticate()
} catch (err) {
throw new SetupAuthError(
"GCP CLI automation failed: " +
(err instanceof Error ? err.message : String(err)),
{ cause: err }
)
}
// --- End automation block ---
await this.initialize()
await this.step_0_checkProject()
await this.step_1_enableServices()
await this.step_2_updateOrCreateBrand()
await this.step_3_determineRedirectUri()
await this.setupForVercelProject()
await this.setupForOpenNextProject()
await this.setupForNetlifyProject()
}
async setupForVercelProject(): Promise<void> {
if (this.platform !== "vercel") return
console.log("\n--- Updating Vercel specifics ---")
if (!this.clientId) {
throw new SetupAuthError("Cannot update Vercel: Client ID is missing.")
}
if (!this.clientSecret) {
console.warn(
"Client Secret is not available (client likely pre-existed). Cannot update Vercel secret variable."
)
}
console.log("Adding all active Vercel deployment redirect URIs...")
const vercelClient = await getVercelClient()
const deploymentUrls = await vercelClient.getDeployments()
const baseOrigin = this.defaultRedirectUri.split("/api/auth")[0]
const callbackPath = "/callback/google"
const allRedirectUris = [
...new Set([
`${baseOrigin}${callbackPath}`,
...deploymentUrls.map((url: string) => `${url}${callbackPath}`),
]),
]
// Fail fast: let errors propagate
await this.oauthClient.updateRedirectUris(this.clientId!, allRedirectUris)
console.log("✅ Successfully updated redirect URIs (Placeholder)")
// await updateVercelWithOAuthCredentials(
// vercelClient,
// this.clientId,
// this.clientSecret,
// this.allowedDomains
// )
// console.log("✅ Attempted Vercel environment variable update.")
}
async setupForOpenNextProject(): Promise<void> {
// Update redirect URIs to include all Vercel deployments
if (this.platform !== "opennext") return
console.log("\n--- Updating OpenNext specifics ---")
}
async setupForNetlifyProject(): Promise<void> {
// Update redirect URIs to include all Netlify deployments
if (this.platform !== "netlify") return
console.log("\n--- Updating Netlify specifics ---")
}
/**
* Step 0: Check if the project is valid
*/
async step_0_checkProject(): Promise<void> {
console.log(
"\n--- Step 0: Check if the project is valid and create if needed... ---"
)
if (await this.projectManager.projectExists(this.projectId)) {
console.log(`✅ GCP project ${this.projectId} already exists`)
return
}
console.log(`Creating GCP project ${this.projectId}...`)
await this.projectManager.createProject(this.projectId)
console.log(`✅ Created GCP project ${this.projectId}`)
}
// Step 1: Enable Required services (running as Service Account)
async step_1_enableServices(): Promise<void> {
await this.initialize()
if (!this.serviceUsageClient) {
throw new SetupAuthError("ServiceUsageClient failed to initialize")
}
console.log("\n--- Step 1: Ensuring required services are enabled... ---")
try {
const [services] = await this.serviceUsageClient.listServices({
parent: `projects/${this.projectId}`,
filter: "state:ENABLED",
})
const enabledApis = new Set<string>(
services
.map(
(service: protos.google.api.serviceusage.v1.IService) =>
service.name?.split("/").pop() || ""
)
.filter(Boolean)
)
// Use imported PUBLIC_SERVICES, casting for comparison
for (const publicApi of PUBLIC_SERVICES) {
enabledApis.add(publicApi)
}
// Use imported REQUIRED_SERVICES
const apisToEnable = Object.values(REQUIRED_SERVICES).filter(
(api: string) =>
!enabledApis.has(api) &&
!(PUBLIC_SERVICES as ReadonlyArray<string>).includes(api)
)
if (apisToEnable.length > 0) {
console.log("APIs to enable:", apisToEnable)
for (const api of apisToEnable) {
console.log(`Enabling API: ${api}...`)
const [operation] = await this.serviceUsageClient.enableService({
name: `projects/${this.projectId}/services/${api}`,
})
await operation.promise()
console.log(`✅ API ${api} enabled successfully.`)
await new Promise(resolve => setTimeout(resolve, 500))
}
} else {
console.log("All required APIs are already enabled.")
}
} catch (error: unknown) {
console.error(
"Failed to enable required APIs using service account:",
error
)
// Extract details carefully from unknown type
let reason = "Unknown Reason"
let details = error instanceof Error ? error.message : String(error)
if (error && typeof error === "object") {
if ("reason" in error) reason = String(error.reason)
if ("details" in error) details = String(error.details)
}
throw new SetupAuthError(
`API enablement failed (Reason: ${reason}). Ensure the service account has necessary permissions (like serviceusage.serviceUsageAdmin) and check project/org policies. Details: ${details}`,
{ cause: error instanceof Error ? error : new Error(details) }
)
}
}
/**
* Step 2: Assume Brand (Consent Screen) was created by setup-service-account.
*/
async step_2_updateOrCreateBrand(): Promise<void> {
console.log("\n--- Step 2: Assuming Brand (Consent Screen) exists... ---")
console.log(
`Proceeding with OAuth client setup assuming brand '${this.oauthBrandName}' exists.`
)
}
async step_3_determineRedirectUri(): Promise<void> {
console.log(
"\n--- Step 3: Determine the redirect URI based on the platform... ---"
)
// Get client ID and secret from environment variables
const envClientId = process.env.GCP_OAUTH_CLIENT_ID
const envClientSecret = process.env.GCP_OAUTH_CLIENT_SECRET
// Check if OAuth credentials are actually present (not empty, not placeholder)
const hasValidClientId =
envClientId && envClientId.trim() !== "" && envClientId !== "PLACEHOLDER"
const hasValidClientSecret =
envClientSecret &&
envClientSecret.trim() !== "" &&
envClientSecret !== "PLACEHOLDER"
if (!hasValidClientId || !hasValidClientSecret) {
// Create a new OAuth client automatically
console.log(
"OAuth credentials not found in .env.local. Creating a new OAuth client..."
)
const displayName = `${this.platform.charAt(0).toUpperCase() + this.platform.slice(1)} OAuth Client`
const redirectUris = [this.defaultRedirectUri]
const origins: string[] = [] // JavaScript origins not needed for server-side auth
try {
const { clientId, clientSecret } = await this.oauthClient.createClient(
displayName,
redirectUris,
origins
)
this.clientId = clientId
this.clientSecret = clientSecret
console.log("\n✅ OAuth client created successfully!")
console.log(`Client ID: ${clientId}`)
console.log(`Client Secret: ${clientSecret}`)
// Automatically save to .env.local
console.log("\nSaving OAuth credentials to .env.local...")
const { updateOrAddEnvVariable } = await import(
"../../../utils/env-handler.js"
)
await updateOrAddEnvVariable("GCP_OAUTH_CLIENT_ID", clientId)
await updateOrAddEnvVariable("GCP_OAUTH_CLIENT_SECRET", clientSecret)
console.log("✅ OAuth credentials saved to .env.local")
return // Client created with correct redirect URI, no need to verify/update
} catch (error) {
throw new SetupAuthError(
"Failed to create OAuth client automatically.",
{ cause: error }
)
}
}
// Use the credentials from environment
this.clientId = envClientId
this.clientSecret = envClientSecret
console.log(`Using OAuth client from .env.local: ${this.clientId}`)
// Check if the OAuth client exists and has the correct redirect URI
try {
console.log("Verifying OAuth client configuration...")
const details = await this.oauthClient.getClientDetails(this.clientId)
console.log(`Current redirect URIs: ${details.redirectUris.join(", ")}`)
if (!details.redirectUris.includes(this.defaultRedirectUri)) {
console.log(`Adding redirect URI: ${this.defaultRedirectUri}`)
const updatedUris = [...details.redirectUris, this.defaultRedirectUri]
await this.oauthClient.updateRedirectUris(this.clientId, updatedUris)
console.log("✅ Redirect URI added successfully")
} else {
console.log("✅ Redirect URI already configured")
}
} catch (error) {
console.error("Error verifying OAuth client:", error)
// Check if this is a Console-created OAuth client
if (this.clientId.endsWith(".apps.googleusercontent.com")) {
throw new SetupAuthError(
`The OAuth client ${this.clientId} appears to be created via Google Cloud Console.\n\n` +
"Console-created OAuth clients cannot be managed via gcloud CLI.\n" +
"You have two options:\n" +
"1. Continue managing this client manually in the Google Cloud Console, OR\n" +
"2. Create a new OAuth client using this tool:\n" +
" - Remove GCP_OAUTH_CLIENT_ID and GCP_OAUTH_CLIENT_SECRET from .env.local\n" +
" - Run this command again to create a new OAuth client automatically\n" +
" - Follow the instructions to retrieve the new client secret\n\n" +
"For more information, see: https://support.google.com/cloud/answer/15549257",
{ cause: error }
)
}
throw new SetupAuthError(
`Failed to verify OAuth client ${this.clientId}. ` +
"Please ensure the client ID in .env.local is correct and the client exists in your GCP project.",
{ cause: error }
)
}
}
private getDefaultRedirectUri(): string {
switch (this.platform) {
case "vercel":
if (!this.vercelProjectName) {
throw new SetupAuthError(
"Vercel project name is required for Vercel platform"
)
}
return `https://${this.vercelProjectName}.vercel.app/api/auth`
case "opennext":
return process.env.PRODUCTION_URL
? `${process.env.PRODUCTION_URL}/api/auth`
: `http://localhost:3000/api/auth`
case "netlify":
return `https://${this.projectId}.netlify.app/api/auth`
default:
throw new SetupAuthError(`Unsupported platform: ${this.platform}`)
}
}
}