From bd770dd84d302728bc50e01ddac731347b185a24 Mon Sep 17 00:00:00 2001 From: Eric Koleda Date: Tue, 5 Mar 2024 21:53:57 -0500 Subject: [PATCH] Revert to user auth. --- bigquery/README.md | 69 +++++++++++++++++++++++++++++++++++++++++++ bigquery/api.ts | 44 ---------------------------- bigquery/helpers.ts | 10 ++++--- bigquery/pack.ts | 71 +++++++++++++++++++++++++++++++++++++++------ 4 files changed, 137 insertions(+), 57 deletions(-) create mode 100644 bigquery/README.md diff --git a/bigquery/README.md b/bigquery/README.md new file mode 100644 index 0000000..8bcda04 --- /dev/null +++ b/bigquery/README.md @@ -0,0 +1,69 @@ +# BigQuery Pack + +This BigQuery Pack can't be published to the Coda gallery due to Google's policies around sensitive scopes. Although it's possible to request Google Cloud scopes during the OAuth flow, Google won't approve those scopes for a public project. Therefore to use this Pack you must deploy your own copy of it with your own OAuth credentials. + +## Setup + +### Pack + +1. Clone this repository. + + ```sh + git clone https://github.com/erickoledadevrel/packs.git + ``` + +1. Install the dependencies. + + ```sh + npm install + ``` + +1. Register an API token. + + ```sh + npx coda register + ``` + +1. Create the Pack. + + ```sh + npx coda create bigquery/pack.ts --name "BigQuery" + ``` + +1. Upload the Pack. + + ``` + npx coda upload bigquery/pack.ts + ``` + +### OAuth + +1. Open the Pack in the Pack Studio and navigate to the **Settings** tab. +1. Click **Add OAuth credentials**. +1. Copy the value shown for **Redirect URL**. +1. In a new tab, open the [Google Cloud Console](https://console.cloud.google.com/). +1. Create or select a project to use with the Pack. +1. Navigate to **APIs & Services > OAuth consent screen**. +1. Select the **User Type** value **Internal**, and click **Create**. + + If you aren't part of a Google Workspace organization you must select **External**. You should keep the project in **Testing** mode, and add yourself as a test user. + +1. Enter a value for all required fields and click **Save and continue**. +1. Click **Add or remove scopes**, and under **Manually add scopes** enter the following: + + ``` + profile + https://www.googleapis.com/auth/bigquery.readonly + https://www.googleapis.com/auth/cloudplatformprojects.readonly + ``` + +1. Click **Add to table**, then **Update**, and finally **Save and continue**. +1. Navigate to the **Credentials** tab. +1. Click **+ Create Credentials > OAuth client ID**. +1. For **Application type** select **Web application**. +1. Under **Authorized redirect URIs** click **+ Add URI**. +1. Paste the URL you copied earlier from the Pack Studio. +1. Click the **Create** button. +1. Copy the **Client ID** and **Client secret**. +1. Back in the Pack Studio OAuth credentials dialog, paste the client ID and secret into the corresponding text boxes. +1. Click **Save**. diff --git a/bigquery/api.ts b/bigquery/api.ts index 6930011..2a1aecb 100644 --- a/bigquery/api.ts +++ b/bigquery/api.ts @@ -1,7 +1,5 @@ import * as coda from "@codahq/packs-sdk"; -import * as rs from "jsrsasign"; import { onError } from "./helpers"; -const ServiceAccountKey = require("./service-account.json"); const QueryPageSize = 100; const QueryTimeoutMs = 30 * 1000; @@ -14,9 +12,6 @@ export class BigQueryApi { constructor(context: coda.ExecutionContext, projectId: string) { this.context = context; this.projectId = projectId; - this.accessTokenPromise = this.getAccessToken(context, [ - "https://www.googleapis.com/auth/bigquery.readonly", - ]); } async runQuery(query: string, dryRun = false, parameters?) { @@ -55,49 +50,10 @@ export class BigQueryApi { return response.body; } - private async getAccessToken(context: coda.ExecutionContext, scopes: string[]) { - // Extract data from the service account key JSON. - let {private_key_id, client_email, private_key, token_uri} = ServiceAccountKey; - - // Construct the JWT header and payload. - let algorithm = "RS256"; - let issued_at = Date.now() / 1000; - let expires_at = issued_at + 3600; - let header = JSON.stringify({ - alg: algorithm, - typ: "JWT", - kid: private_key_id, - }); - let payload = JSON.stringify({ - iss: client_email, - aud: token_uri, - exp: expires_at, - iat: issued_at, - sub: client_email, - scope: scopes.join(" "), - }); - - // Generate the signed JWT. - let jwt = rs.jws.JWS.sign(algorithm, header, payload, rs.KEYUTIL.getKey(private_key)); - - // Use the JWT to fetch an ID token from the token endpoint. - let response = await context.fetcher.fetch({ - method: "POST", - url: token_uri, - form: { - grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", - assertion: jwt, - } - }); - let data = response.body; - return data.access_token; - } - private async makeRequest(request: coda.FetchRequest) { if (request.url?.startsWith("/")) { request.url = coda.joinUrl("https://www.googleapis.com/bigquery/v2/projects", this.projectId, request.url); } - request.headers["Authorization"] = "Bearer " + await this.accessTokenPromise; try { return await this.context.fetcher.fetch(request); } catch (error) { diff --git a/bigquery/helpers.ts b/bigquery/helpers.ts index ab6cec8..989043f 100644 --- a/bigquery/helpers.ts +++ b/bigquery/helpers.ts @@ -1,8 +1,12 @@ import * as coda from "@codahq/packs-sdk"; -const crypto = require("crypto"); +import * as crypto from "crypto"; import { ObjectTypeKey } from "./schemas"; import { BaseQueryObjectSchema } from "./schemas"; +export function getProjectId(context: coda.ExecutionContext) { + return context.endpoint.split("#").pop(); +} + export function formatObjectValue(obj, schema) { let result: Record = {}; let fieldValues = obj.f; @@ -90,11 +94,9 @@ export function randomId() { } export function onError(error: Error) { - console.log("onError"); + console.error(error) if (coda.StatusCodeError.isStatusCodeError(error) && error.statusCode != 401 && error.body?.error?.message) { - console.log(JSON.stringify(error.body)); let message = error.body.error.message; - console.log(message); if (message.includes("CloudRegion")) { console.log("match"); message = "Invalid project ID, or access hasn't been granted."; diff --git a/bigquery/pack.ts b/bigquery/pack.ts index 78de373..df2d612 100644 --- a/bigquery/pack.ts +++ b/bigquery/pack.ts @@ -1,12 +1,67 @@ import * as coda from "@codahq/packs-sdk"; -import { randomId, getSchema, formatObjectValue, getHash, maybeParseJsonList } from "./helpers"; +import { randomId, getSchema, formatObjectValue, getHash, maybeParseJsonList, getProjectId } from "./helpers"; import { BigQueryApi } from "./api"; import { BaseQueryRowSchema, RowIdKey, RowIndexKey } from "./schemas"; export const pack = coda.newPack(); +const ListProjectsPageSize = 100; +const OneDaySecs = 24 * 60 * 60; + pack.addNetworkDomain("googleapis.com"); +pack.setUserAuthentication({ + type: coda.AuthenticationType.OAuth2, + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: "https://oauth2.googleapis.com/token", + scopes: [ + "profile", + "https://www.googleapis.com/auth/bigquery.readonly", + "https://www.googleapis.com/auth/cloudplatformprojects.readonly", + ], + additionalParams: { + access_type: "offline", + prompt: "consent", + }, + postSetup: [ + { + type: coda.PostSetupType.SetEndpoint, + name: "SelectProject", + description: "Select the Google Cloud Console project to bill the queries to.", + getOptions: async function (context) { + let url = coda.withQueryParams("https://cloudresourcemanager.googleapis.com/v1/projects", { + filter: "lifecycleState:ACTIVE", + pageSize: ListProjectsPageSize, + }); + let response = await context.fetcher.fetch({ + method: "GET", + url: url, + }); + let data = response.body; + return data.projects.map(project => { + return { + display: `${project.name} (${project.projectId})`, + value: `https://www.googleapis.com/#${project.projectId}`, + }; + }); + }, + }, + ], + getConnectionName: async function (context) { + if (!context.endpoint) { + return "Incomplete"; + } + let projectId = getProjectId(context); + let response = await context.fetcher.fetch({ + method: "GET", + url: "https://www.googleapis.com/oauth2/v1/userinfo", + cacheTtlSecs: OneDaySecs, + }); + let user = response.body; + return `${projectId} (${user.name})`; + }, +}); + pack.addDynamicSyncTable({ name: "Query", description: "Run a query and load the results into a table.", @@ -23,7 +78,8 @@ pack.addDynamicSyncTable({ return "Query results"; }, getSchema: async function (context, _, args) { - let { projectId, query, parameters } = args; + let { query, parameters } = args; + let projectId = getProjectId(context); parameters = maybeParseJsonList(parameters); if (!query) { return BaseQueryRowSchema; @@ -39,11 +95,6 @@ pack.addDynamicSyncTable({ name: "SyncQuery", description: "Syncs the data.", parameters: [ - coda.makeParameter({ - type: coda.ParameterType.String, - name: "projectId", - description: "The ID of the Google Cloud project to bill the queries to.", - }), coda.makeParameter({ type: coda.ParameterType.String, name: "query", @@ -55,7 +106,8 @@ pack.addDynamicSyncTable({ description: "Which columns, if their values are combined, will form a unique ID for the row.", optional: true, autocomplete: async function (context, _, args) { - let { projectId, query } = args; + let { query } = args; + let projectId = getProjectId(context); if (!query) { return []; } @@ -72,7 +124,8 @@ pack.addDynamicSyncTable({ }), ], execute: async function (args, context) { - let [projectId, query, uniqueColumns, parameters] = args; + let [query, uniqueColumns, parameters] = args; + let projectId = getProjectId(context); parameters = maybeParseJsonList(parameters); let jobId = context.sync.continuation?.jobId as string;