diff --git a/lib/actions/google/drive/google_drive.d.ts b/lib/actions/google/drive/google_drive.d.ts index 8d9ea2371..7ca820f2d 100644 --- a/lib/actions/google/drive/google_drive.d.ts +++ b/lib/actions/google/drive/google_drive.d.ts @@ -3,7 +3,7 @@ import { Credentials, OAuth2Client } from "google-auth-library"; import { drive_v3 } from "googleapis"; import * as Hub from "../../../hub"; import Drive = drive_v3.Drive; -export declare class GoogleDriveAction extends Hub.OAuthAction { +export declare class GoogleDriveAction extends Hub.OAuthActionV2 { name: string; label: string; iconName: string; @@ -23,9 +23,10 @@ export declare class GoogleDriveAction extends Hub.OAuthAction { execute(request: Hub.ActionRequest): Promise; form(request: Hub.ActionRequest): Promise; oauthUrl(redirectUri: string, encryptedState: string): Promise; - oauthFetchInfo(urlParams: { + oauthHandleRedirect(urlParams: { [key: string]: string; - }, redirectUri: string): Promise; + }, redirectUri: string): Promise; + oauthFetchAccessToken(request: Hub.ActionRequest): Promise; oauthCheck(request: Hub.ActionRequest): Promise; oauth2Client(redirectUri: string | undefined): OAuth2Client; sendData(filename: string, request: Hub.ActionRequest, drive: Drive): Promise>; diff --git a/lib/actions/google/drive/google_drive.js b/lib/actions/google/drive/google_drive.js index daa2b082f..1e839ff3c 100644 --- a/lib/actions/google/drive/google_drive.js +++ b/lib/actions/google/drive/google_drive.js @@ -11,7 +11,7 @@ const action_response_1 = require("../../../hub/action_response"); const domain_validator_1 = require("./domain_validator"); const LOG_PREFIX = "[GOOGLE_DRIVE]"; const FOLDERID_REGEX = /\/folders\/(?[^\/?]+)/; -class GoogleDriveAction extends Hub.OAuthAction { +class GoogleDriveAction extends Hub.OAuthActionV2 { constructor() { super(...arguments); this.name = "google_drive"; @@ -218,19 +218,50 @@ class GoogleDriveAction extends Hub.OAuthAction { }); return url.toString(); } - async oauthFetchInfo(urlParams, redirectUri) { + async oauthHandleRedirect(urlParams, redirectUri) { const actionCrypto = new Hub.ActionCrypto(); const plaintext = await actionCrypto.decrypt(urlParams.state).catch((err) => { winston.error("Encryption not correctly configured" + err); throw err; }); - const tokens = await this.getAccessTokenCredentialsFromCode(redirectUri, urlParams.code); - // Pass back context to Looker - const payload = JSON.parse(plaintext); - await https.post({ - url: payload.stateurl, - body: JSON.stringify({ tokens, redirect: redirectUri }), - }).catch((_err) => { winston.error(_err.toString()); }); + const statePayload = JSON.parse(plaintext); + if (statePayload.hasOwnProperty("tokenurl")) { + // redirect user back to Looker with context + const newState = { + code: urlParams.code, + redirecturi: redirectUri, + }; + const jsonString = JSON.stringify(newState); + const ciphertextBlob = await actionCrypto.encrypt(jsonString).catch((err) => { + winston.error("Encryption not correctly configured"); + throw err; + }); + return `${statePayload.tokenurl}?state=${ciphertextBlob}`; + } + else { + // Pass back context to Looker + const tokens = await this.getAccessTokenCredentialsFromCode(redirectUri, urlParams.code); + await https.post({ + url: statePayload.stateurl, + body: JSON.stringify({ tokens, redirect: redirectUri }), + }).catch((_err) => { winston.error(_err.toString()); }); + return ""; + } + } + async oauthFetchAccessToken(request) { + if (request.params.state) { + const actionCrypto = new Hub.ActionCrypto(); + const plaintext = await actionCrypto.decrypt(request.params.state).catch((err) => { + winston.error("Encryption not correctly configured" + err); + throw err; + }); + const state = JSON.parse(plaintext); + const tokens = await this.getAccessTokenCredentialsFromCode(state.redirecturi, state.code); + return JSON.stringify({ tokens, redirect: state.redirecturi }); + } + else { + throw new Error("Request is missing state parameter."); + } } async oauthCheck(request) { if (request.params.state_json) { @@ -389,8 +420,10 @@ class GoogleDriveAction extends Hub.OAuthAction { async loginForm(request) { const form = new Hub.ActionForm(); form.fields = []; + const hasTokenUrl = request.params.hasOwnProperty("state_redir_url"); + const state = hasTokenUrl ? { tokenurl: request.params.state_redir_url } : { stateurl: request.params.state_url }; + const jsonString = JSON.stringify(state); const actionCrypto = new Hub.ActionCrypto(); - const jsonString = JSON.stringify({ stateurl: request.params.state_url }); const ciphertextBlob = await actionCrypto.encrypt(jsonString).catch((err) => { winston.error("Encryption not correctly configured"); throw err; diff --git a/lib/hub/action.d.ts b/lib/hub/action.d.ts index 37a66a4eb..44a09983a 100644 --- a/lib/hub/action.d.ts +++ b/lib/hub/action.d.ts @@ -20,6 +20,7 @@ export interface Action { export interface RouteBuilder { actionUrl(action: Action): string; formUrl(action: Action): string; + tokenUrl(action: Action): string; } export declare abstract class Action { get hasForm(): boolean; @@ -54,6 +55,7 @@ export declare abstract class Action { supported_download_settings: ActionDownloadSettings[]; icon_data_uri: any; url: string; + token_url: string; }; validateAndExecute(request: ActionRequest, queue?: ProcessQueue): Promise; validateAndFetchForm(request: ActionRequest): Promise; diff --git a/lib/hub/action.js b/lib/hub/action.js index 650d202b9..c0267964e 100644 --- a/lib/hub/action.js +++ b/lib/hub/action.js @@ -40,6 +40,7 @@ class Action { [_1.ActionDownloadSettings.Push]), icon_data_uri: this.getImageDataUri(), url: router.actionUrl(this), + token_url: "", }; } async validateAndExecute(request, queue) { diff --git a/lib/hub/index.d.ts b/lib/hub/index.d.ts index 311e1561c..e1c23ed34 100644 --- a/lib/hub/index.d.ts +++ b/lib/hub/index.d.ts @@ -4,6 +4,7 @@ export * from "./action_state"; export * from "./action_response"; export * from "./action"; export * from "./oauth_action"; +export * from "./oauth_action_v2"; export * from "./delegate_oauth_action"; export * from "./sources"; export * from "./utils"; diff --git a/lib/hub/index.js b/lib/hub/index.js index 10c0e8a21..ff8fa157f 100644 --- a/lib/hub/index.js +++ b/lib/hub/index.js @@ -22,6 +22,7 @@ __exportStar(require("./action_state"), exports); __exportStar(require("./action_response"), exports); __exportStar(require("./action"), exports); __exportStar(require("./oauth_action"), exports); +__exportStar(require("./oauth_action_v2"), exports); __exportStar(require("./delegate_oauth_action"), exports); __exportStar(require("./sources"), exports); __exportStar(require("./utils"), exports); diff --git a/lib/hub/oauth_action_v2.d.ts b/lib/hub/oauth_action_v2.d.ts new file mode 100644 index 000000000..3f649897c --- /dev/null +++ b/lib/hub/oauth_action_v2.d.ts @@ -0,0 +1,12 @@ +import { Action, RouteBuilder } from "./action"; +import { ActionRequest } from "./action_request"; +export declare abstract class OAuthActionV2 extends Action { + abstract oauthCheck(request: ActionRequest): Promise; + abstract oauthUrl(redirectUri: string, encryptedState: string): Promise; + abstract oauthHandleRedirect(urlParams: { + [key: string]: string; + }, redirectUri: string): Promise; + abstract oauthFetchAccessToken(request: ActionRequest): Promise; + asJson(router: RouteBuilder, request: ActionRequest): any; +} +export declare function isOauthActionV2(action: Action): boolean; diff --git a/lib/hub/oauth_action_v2.js b/lib/hub/oauth_action_v2.js new file mode 100644 index 000000000..f8e221551 --- /dev/null +++ b/lib/hub/oauth_action_v2.js @@ -0,0 +1,17 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.OAuthActionV2 = void 0; +exports.isOauthActionV2 = isOauthActionV2; +const action_1 = require("./action"); +class OAuthActionV2 extends action_1.Action { + asJson(router, request) { + const json = super.asJson(router, request); + json.uses_oauth = true; + json.token_url = router.tokenUrl(this); + return json; + } +} +exports.OAuthActionV2 = OAuthActionV2; +function isOauthActionV2(action) { + return action instanceof OAuthActionV2; +} diff --git a/lib/server/server.d.ts b/lib/server/server.d.ts index c86b5e313..84c10d4cc 100644 --- a/lib/server/server.d.ts +++ b/lib/server/server.d.ts @@ -8,6 +8,7 @@ export default class Server implements Hub.RouteBuilder { constructor(); actionUrl(action: Hub.Action): string; formUrl(action: Hub.Action): string; + tokenUrl(action: Hub.Action): string; private oauthRedirectUrl; /** * For JSON responses that take a long time without sending any data, diff --git a/lib/server/server.js b/lib/server/server.js index 5525b4515..220ee48c8 100644 --- a/lib/server/server.js +++ b/lib/server/server.js @@ -130,7 +130,7 @@ class Server { this.app.get("/actions/:actionId/oauth", async (req, res) => { const request = Hub.ActionRequest.fromRequest(req); const action = await Hub.findAction(req.params.actionId, { lookerVersion: request.lookerVersion }); - if ((0, hub_1.isOauthAction)(action)) { + if ((0, hub_1.isOauthAction)(action) || (0, hub_1.isOauthActionV2)(action)) { const parts = uparse.parse(req.url, true); const state = parts.query.state; const url = await action.oauthUrl(this.oauthRedirectUrl(action), state); @@ -143,7 +143,7 @@ class Server { this.app.get("/actions/:actionId/oauth_check", async (req, res) => { const request = Hub.ActionRequest.fromRequest(req); const action = await Hub.findAction(req.params.actionId, { lookerVersion: request.lookerVersion }); - if ((0, hub_1.isOauthAction)(action)) { + if ((0, hub_1.isOauthAction)(action) || (0, hub_1.isOauthActionV2)(action)) { const check = action.oauthCheck(request); res.json(check); } @@ -155,19 +155,41 @@ class Server { this.app.get("/actions/:actionId/oauth_redirect", async (req, res) => { const request = Hub.ActionRequest.fromRequest(req); const action = await Hub.findAction(req.params.actionId, { lookerVersion: request.lookerVersion }); - if ((0, hub_1.isOauthAction)(action)) { - try { + try { + if ((0, hub_1.isOauthAction)(action)) { await action.oauthFetchInfo(req.query, this.oauthRedirectUrl(action)); res.statusCode = 200; - res.send(`>You may now close this tab.`); + res.send(`You may now close this tab.`); } - catch (e) { - this.logPromiseFail(req, res, e); - res.statusCode = 400; + else if ((0, hub_1.isOauthActionV2)(action)) { + const redirUrl = await action.oauthHandleRedirect(req.query, this.oauthRedirectUrl(action)); + if (redirUrl === "") { + res.statusCode = 200; + res.send(`You may now close this tab.`); + } + else { + res.redirect(redirUrl); + } } + else { + throw "Action does not support OAuth."; + } + } + catch (e) { + this.logPromiseFail(req, res, e); + res.statusCode = 400; + } + }); + this.route("/actions/:actionId/oauth_token", async (req, res) => { + const request = Hub.ActionRequest.fromRequest(req); + const action = await Hub.findAction(req.params.actionId, { lookerVersion: request.lookerVersion }); + if ((0, hub_1.isOauthActionV2)(action)) { + const jsonPayload = await action.oauthFetchAccessToken(request); + res.type("json"); + res.send(jsonPayload); } else { - throw "Action does not support OAuth."; + res.statusCode = 404; } }); // To provide a health or version check endpoint you should place a status.json file @@ -182,6 +204,9 @@ class Server { formUrl(action) { return this.absUrl(`/actions/${encodeURIComponent(action.name)}/form`); } + tokenUrl(action) { + return this.absUrl(`/actions/${encodeURIComponent(action.name)}/oauth_token`); + } oauthRedirectUrl(action) { return this.absUrl(`/actions/${encodeURIComponent(action.name)}/oauth_redirect`); } diff --git a/src/actions/google/drive/google_drive.ts b/src/actions/google/drive/google_drive.ts index d39ee0cfa..1f2d72fee 100644 --- a/src/actions/google/drive/google_drive.ts +++ b/src/actions/google/drive/google_drive.ts @@ -16,7 +16,7 @@ import { DomainValidator } from "./domain_validator" const LOG_PREFIX = "[GOOGLE_DRIVE]" const FOLDERID_REGEX = /\/folders\/(?[^\/?]+)/ -export class GoogleDriveAction extends Hub.OAuthAction { +export class GoogleDriveAction extends Hub.OAuthActionV2 { name = "google_drive" label = "Google Drive" iconName = "google/drive/google_drive.svg" @@ -257,21 +257,52 @@ export class GoogleDriveAction extends Hub.OAuthAction { return url.toString() } - async oauthFetchInfo(urlParams: { [key: string]: string }, redirectUri: string) { + async oauthHandleRedirect(urlParams: { [key: string]: string }, redirectUri: string) { const actionCrypto = new Hub.ActionCrypto() const plaintext = await actionCrypto.decrypt(urlParams.state).catch((err: string) => { winston.error("Encryption not correctly configured" + err) throw err }) - const tokens = await this.getAccessTokenCredentialsFromCode(redirectUri, urlParams.code) + const statePayload = JSON.parse(plaintext) + if (statePayload.hasOwnProperty("tokenurl")) { + // redirect user back to Looker with context + const newState = { + code: urlParams.code, + redirecturi: redirectUri, + } + const jsonString = JSON.stringify(newState) + const ciphertextBlob = await actionCrypto.encrypt(jsonString).catch((err: string) => { + winston.error("Encryption not correctly configured") + throw err + }) + + return `${statePayload.tokenurl}?state=${ciphertextBlob}` + } else { + // Pass back context to Looker + const tokens = await this.getAccessTokenCredentialsFromCode(redirectUri, urlParams.code) + await https.post({ + url: statePayload.stateurl, + body: JSON.stringify({tokens, redirect: redirectUri}), + }).catch((_err) => { winston.error(_err.toString()) }) + return "" + } + } - // Pass back context to Looker - const payload = JSON.parse(plaintext) - await https.post({ - url: payload.stateurl, - body: JSON.stringify({tokens, redirect: redirectUri}), - }).catch((_err) => { winston.error(_err.toString()) }) + async oauthFetchAccessToken(request: Hub.ActionRequest) { + if (request.params.state) { + const actionCrypto = new Hub.ActionCrypto() + const plaintext = await actionCrypto.decrypt(request.params.state).catch((err: string) => { + winston.error("Encryption not correctly configured" + err) + throw err + }) + const state = JSON.parse(plaintext) + + const tokens = await this.getAccessTokenCredentialsFromCode(state.redirecturi, state.code) + return JSON.stringify({tokens, redirect: state.redirecturi}) + } else { + throw new Error("Request is missing state parameter.") + } } async oauthCheck(request: Hub.ActionRequest) { @@ -450,8 +481,11 @@ export class GoogleDriveAction extends Hub.OAuthAction { const form = new Hub.ActionForm() form.fields = [] + const hasTokenUrl = request.params.hasOwnProperty("state_redir_url") + const state = hasTokenUrl ? {tokenurl: request.params.state_redir_url} : {stateurl: request.params.state_url} + const jsonString = JSON.stringify(state) + const actionCrypto = new Hub.ActionCrypto() - const jsonString = JSON.stringify({stateurl: request.params.state_url}) const ciphertextBlob = await actionCrypto.encrypt(jsonString).catch((err: string) => { winston.error("Encryption not correctly configured") throw err diff --git a/src/actions/google/drive/test_google_drive.ts b/src/actions/google/drive/test_google_drive.ts index 9741184a7..fd6337ed8 100644 --- a/src/actions/google/drive/test_google_drive.ts +++ b/src/actions/google/drive/test_google_drive.ts @@ -208,6 +208,35 @@ describe(`${action.constructor.name} unit tests`, () => { }).and.notify(stubClient.restore).and.notify(done) }) + it("returns an oauth form on bad login when v2 flow param is provided", (done) => { + const stubClient = sinon.stub(action as any, "driveClientFromRequest") + .resolves({ + files: { + list: async () => Promise.reject("reject"), + }, + }) + const request = new Hub.ActionRequest() + request.params = { + state_redir_url: "https://looker.state.url.com/action_hub_fetch_state/asdfasdfasdfasdf", + state_json: JSON.stringify({tokens: "access", redirect: "url"}), + } + request.webhookId = "testId" + const form = action.validateAndFetchForm(request) + chai.expect(form).to.eventually.deep.equal({ + fields: [{ + name: "login", + type: "oauth_link_google", + description: "In order to send to Google Drive, you will need to log in" + + " once to your Google account. WebhookID if oauth fails: testId", + label: "Log in", + oauth_url: `${process.env.ACTION_HUB_BASE_URL}/actions/google_drive/` + + `oauth?state=eyJ0b2tlbnVybCI6Imh0dHBzOi8vbG9va2VyLnN0YXRlLnVybC5jb` + + `20vYWN0aW9uX2h1Yl9mZXRjaF9zdGF0ZS9hc2RmYXNkZmFzZGZhc2RmIn0`, + }], + state: {}, + }).and.notify(stubClient.restore).and.notify(done) + }) + it("does not blow up on bad state JSON and returns an OAUTH form", (done) => { const stubClient = sinon.stub(action as any, "driveClientFromRequest") .resolves({ @@ -237,6 +266,35 @@ describe(`${action.constructor.name} unit tests`, () => { }).and.notify(stubClient.restore).and.notify(done) }) + it("does not blow up on bad state JSON and returns an OAUTH form when v2 param is provided", (done) => { + const stubClient = sinon.stub(action as any, "driveClientFromRequest") + .resolves({ + files: { + list: async () => Promise.reject("reject"), + }, + }) + const request = new Hub.ActionRequest() + request.params = { + state_redir_url: "https://looker.state.url.com/action_hub_fetch_state/asdfasdfasdfasdf", + state_json: JSON.stringify({bad: "access", redirect: "url"}), + } + request.webhookId = "testId" + const form = action.validateAndFetchForm(request) + chai.expect(form).to.eventually.deep.equal({ + fields: [{ + name: "login", + type: "oauth_link_google", + description: "In order to send to Google Drive, you will need to log in" + + " once to your Google account. WebhookID if oauth fails: testId", + label: "Log in", + oauth_url: `${process.env.ACTION_HUB_BASE_URL}/actions/google_drive/` + + `oauth?state=eyJ0b2tlbnVybCI6Imh0dHBzOi8vbG9va2VyLnN0YXRlLnVybC5jb` + + `20vYWN0aW9uX2h1Yl9mZXRjaF9zdGF0ZS9hc2RmYXNkZmFzZGZhc2RmIn0`, + }], + state: {}, + }).and.notify(stubClient.restore).and.notify(done) + }) + it("returns correct fields on oauth success", (done) => { const stubClient = sinon.stub(action as any, "driveClientFromRequest") .resolves({ @@ -315,17 +373,46 @@ describe(`${action.constructor.name} unit tests`, () => { return chai.expect(prom).to.eventually.equal("https://accounts.google.com/o/oauth2/v2/auth?access_type=offline&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fdrive%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email&prompt=consent&state=eyJzdGF0ZXVybCI6Imh0dHBzOi8vbG9va2VyLnN0YXRlLnVybC5jb20vYWN0aW9uX2h1Yl9zdGF0ZS9hc2RmYXNkZmFzZGZhc2RmIn0&response_type=code&client_id=testingkey&redirect_uri=https%3A%2F%2Factionhub.com%2Factions%2Fgoogle_drive%2Foauth_redirect") }) - it("correctly handles redirect from authorization server", (done) => { - const stubAccessToken = sinon.stub(action as any, "getAccessTokenCredentialsFromCode").resolves({tokens: "token"}) - // @ts-ignore - const stubReq = sinon.stub(https, "post").callsFake(async () => Promise.resolve({access_token: "token"})) - const result = action.oauthFetchInfo({code: "code", - state: `eyJzdGF0ZXVybCI6Imh0dHBzOi8vbG9va2VyLnN0YXRlLnVybC5jb20vYWN0aW9uX2h1Yl9zdGF0ZS9hc2RmYXNkZmFzZGZh` + - `c2RmIiwiYXBwIjoibXlrZXkifQ`}, - "redirect") - chai.expect(result) - .and.notify(stubAccessToken.restore) - .and.notify(stubReq.restore).and.notify(done) + describe("v1 flow", () => { + it("correctly handles redirect from authorization server", (done) => { + const stubAccessToken = sinon + .stub(action as any, "getAccessTokenCredentialsFromCode") + .resolves({tokens: "token"}) + // @ts-ignore + const stubReq = sinon.stub(https, "post").callsFake(async () => Promise.resolve({access_token: "token"})) + const redirectUrl = action.oauthHandleRedirect({code: "code", + state: `eyJzdGF0ZXVybCI6Imh0dHBzOi8vbG9va2VyLnN0YXRlLnVybC5jb20vYWN0aW9uX2h1Yl9zdGF0ZS9hc2RmYXNkZmFzZGZh` + + `c2RmIiwiYXBwIjoibXlrZXkifQ`}, + "redirect") + chai.expect(redirectUrl).to.eventually.equal("") + .and.notify(stubAccessToken.restore) + .and.notify(stubReq.restore).and.notify(done) + }) + }) + + describe ("v2 flow", () => { + it("correctly handles redirect from authorization server", (done) => { + // @ts-ignore + const redirectUrl = action.oauthHandleRedirect({code: "code", + state: `eyJ0b2tlbnVybCI6Imh0dHBzOi8vbG9va2VyLnN0YXRlLnVybC5jb` + + `20vYWN0aW9uX2h1Yl9mZXRjaF9zdGF0ZS9hc2RmYXNkZmFzZGZhc2RmIn0`}, + "redirect") + chai.expect(redirectUrl).to.eventually.equal("https://looker.state.url.com/action_hub_fetch_state/asdfasdfasdfasdf?state=eyJjb2RlIjoiY29kZSIsInJlZGlyZWN0dXJpIjoicmVkaXJlY3QifQ") + .and.notify(done) + }) + + it("correctly handles request from Looker to fetch token", (done) => { + const stubAccessToken = sinon.stub(action as any, "getAccessTokenCredentialsFromCode") + .resolves({tokens: "token"}) + const request = new Hub.ActionRequest() + request.params = { + state: "eyJjb2RlIjoiY29kZSIsInJlZGlyZWN0dXJpIjoicmVkaXJlY3QifQ"} + request.webhookId = "testId" + const tokenPayload = action.oauthFetchAccessToken(request) + chai.expect(tokenPayload).to.eventually.equal(`{"tokens":{"tokens":"token"},"redirect":"redirect"}`) + .and.notify(stubAccessToken.restore) + .and.notify(done) + }) }) }) diff --git a/src/hub/action.ts b/src/hub/action.ts index 29833c68b..086947012 100644 --- a/src/hub/action.ts +++ b/src/hub/action.ts @@ -39,6 +39,7 @@ export interface Action { export interface RouteBuilder { actionUrl(action: Action): string formUrl(action: Action): string + tokenUrl(action: Action): string } export abstract class Action { @@ -91,6 +92,7 @@ export abstract class Action { ), icon_data_uri: this.getImageDataUri(), url: router.actionUrl(this), + token_url: "", } } diff --git a/src/hub/index.ts b/src/hub/index.ts index 5243a29ab..023478d20 100644 --- a/src/hub/index.ts +++ b/src/hub/index.ts @@ -4,6 +4,7 @@ export * from "./action_state" export * from "./action_response" export * from "./action" export * from "./oauth_action" +export * from "./oauth_action_v2" export * from "./delegate_oauth_action" export * from "./sources" export * from "./utils" diff --git a/src/hub/oauth_action_v2.ts b/src/hub/oauth_action_v2.ts new file mode 100644 index 000000000..a4d284f52 --- /dev/null +++ b/src/hub/oauth_action_v2.ts @@ -0,0 +1,20 @@ +import {Action, RouteBuilder} from "./action" +import {ActionRequest} from "./action_request" + +export abstract class OAuthActionV2 extends Action { + abstract oauthCheck(request: ActionRequest): Promise + abstract oauthUrl(redirectUri: string, encryptedState: string): Promise + abstract oauthHandleRedirect(urlParams: { [key: string]: string }, redirectUri: string): Promise + abstract oauthFetchAccessToken(request: ActionRequest): Promise + + asJson(router: RouteBuilder, request: ActionRequest): any { + const json = super.asJson(router, request) + json.uses_oauth = true + json.token_url = router.tokenUrl(this) + return json + } +} + +export function isOauthActionV2(action: Action): boolean { + return action instanceof OAuthActionV2 +} diff --git a/src/server/server.ts b/src/server/server.ts index d4bf00b16..6294ffac5 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -4,7 +4,7 @@ import * as path from "path" import * as Raven from "raven" import * as winston from "winston" import * as Hub from "../hub" -import {DelegateOAuthAction, isDelegateOauthAction, isOauthAction, OAuthAction} from "../hub" +import {DelegateOAuthAction, isDelegateOauthAction, isOauthAction, isOauthActionV2, OAuthAction, OAuthActionV2} from "../hub" import * as ExecuteProcessQueue from "../xpc/execute_process_queue" import * as ExtendedProcessQueue from "../xpc/extended_process_queue" import * as apiKey from "./api_key" @@ -148,7 +148,7 @@ export default class Server implements Hub.RouteBuilder { this.app.get("/actions/:actionId/oauth", async (req, res) => { const request = Hub.ActionRequest.fromRequest(req) const action = await Hub.findAction(req.params.actionId, { lookerVersion: request.lookerVersion }) - if (isOauthAction(action)) { + if (isOauthAction(action) || isOauthActionV2(action)) { const parts = uparse.parse(req.url, true) const state = parts.query.state const url = await (action as OAuthAction).oauthUrl(this.oauthRedirectUrl(action), state) @@ -161,7 +161,7 @@ export default class Server implements Hub.RouteBuilder { this.app.get("/actions/:actionId/oauth_check", async (req, res) => { const request = Hub.ActionRequest.fromRequest(req) const action = await Hub.findAction(req.params.actionId, {lookerVersion: request.lookerVersion}) - if (isOauthAction(action)) { + if (isOauthAction(action) || isOauthActionV2(action)) { const check = (action as OAuthAction).oauthCheck(request) res.json(check) } else { @@ -173,18 +173,40 @@ export default class Server implements Hub.RouteBuilder { this.app.get("/actions/:actionId/oauth_redirect", async (req, res) => { const request = Hub.ActionRequest.fromRequest(req) const action = await Hub.findAction(req.params.actionId, { lookerVersion: request.lookerVersion }) - if (isOauthAction(action)) { - try { + try { + if (isOauthAction(action)) { await (action as OAuthAction).oauthFetchInfo(req.query as {[key: string]: string}, this.oauthRedirectUrl(action)) res.statusCode = 200 - res.send(`>You may now close this tab.`) - } catch (e: any) { - this.logPromiseFail(req, res, e) - res.statusCode = 400 + res.send(`You may now close this tab.`) + } else if (isOauthActionV2(action)) { + const redirUrl = await (action as OAuthActionV2).oauthHandleRedirect(req.query as {[key: string]: string}, + this.oauthRedirectUrl(action)) + if (redirUrl === "") { + res.statusCode = 200 + res.send(`You may now close this tab.`) + } else { + res.redirect(redirUrl) + } + } else { + throw "Action does not support OAuth." } + } catch (e: any) { + this.logPromiseFail(req, res, e) + res.statusCode = 400 + } + }) + + this.route("/actions/:actionId/oauth_token", async (req, res) => { + const request = Hub.ActionRequest.fromRequest(req) + const action = await Hub.findAction(req.params.actionId, { lookerVersion: request.lookerVersion }) + + if (isOauthActionV2(action)) { + const jsonPayload = await (action as OAuthActionV2).oauthFetchAccessToken(request) + res.type("json") + res.send(jsonPayload) } else { - throw "Action does not support OAuth." + res.statusCode = 404 } }) @@ -204,6 +226,10 @@ export default class Server implements Hub.RouteBuilder { return this.absUrl(`/actions/${encodeURIComponent(action.name)}/form`) } + tokenUrl(action: Hub.Action) { + return this.absUrl(`/actions/${encodeURIComponent(action.name)}/oauth_token`) + } + private oauthRedirectUrl(action: Hub.Action) { return this.absUrl(`/actions/${encodeURIComponent(action.name)}/oauth_redirect`) } diff --git a/test/test_smoke.ts b/test/test_smoke.ts index 79bd92493..066e99c22 100644 --- a/test/test_smoke.ts +++ b/test/test_smoke.ts @@ -23,6 +23,9 @@ before(async () => { formUrl(i) { return `baseurl/${i.name}` }, + tokenUrl(i) { + return `baseurl/${i.name}` + }, }, new Hub.ActionRequest()) chai.assert.typeOf(json.url, "string") })