From b3861db6e68da95cce6dffba08e031c557c5b5d4 Mon Sep 17 00:00:00 2001 From: Andrej Antal Date: Tue, 22 Nov 2022 16:21:51 +0100 Subject: [PATCH] fix: change google oauth OOB flow to server --- .gitignore | 3 +- README.md | 9 +++- package.json | 3 +- src/scripts/getRefreshToken.test.ts | 29 ++++-------- src/scripts/getRefreshToken.ts | 73 +++++++++++++++++------------ src/scripts/index.ts | 61 +++++++++++++++++++----- src/scripts/inputHandler.ts | 15 ------ yarn.lock | 18 +++++++ 8 files changed, 128 insertions(+), 83 deletions(-) delete mode 100644 src/scripts/inputHandler.ts diff --git a/.gitignore b/.gitignore index 182ded7..881a6e8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules dist -.nyc_output \ No newline at end of file +.nyc_output +.idea \ No newline at end of file diff --git a/README.md b/README.md index 1883560..769727a 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,13 @@ CLIENT_ID - OAuth Client ID for authenticating from a desktop app CLIENT_SECRET - OAuth Client secret for authenticating from a desktop app ``` +**These are optional environment variables:** + +``` +GOOGLE_REDIRECT_URI - local server uri, which uses google OAuth for redirecting (default 'http://localhost:3000') +OAUTH_SERVER_PORT - your local server port (default '3000') +``` + `package.json` ```json @@ -150,7 +157,7 @@ CLIENT_SECRET - OAuth Client secret for authenticating from a desktop app } ``` -Now run `generate:token` a browser will open displaying an authorization code (starting with `4/`) and the CLI will ask you to input the authorization code. After that you will be provided with a `refresh_token` (starting with `1/`) that has long validity and can be used for local development. +Now run `generate:token`. This script will start local server (on default port 3000) and the server will automatically open your browser with Google's OAuth web page for your application. After clicking on the button, Google will redirect you back to your GOOGLE_REDIRECT_URI (which defaults to http://localhost:3000) and you will be provided with a `refresh_token` (starting with `1/`) that has long validity and can be used for local development. # Contributing diff --git a/package.json b/package.json index 0aac2c3..c03a667 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "jsonwebtoken": "^8.5.1", "mamushi": "^2.0.0", "node-fetch": "^2.6.0", - "open": "^8.4.0" + "open": "^8.4.0", + "url": "^0.11.0" }, "devDependencies": { "@semantic-release/commit-analyzer": "9.0.2", diff --git a/src/scripts/getRefreshToken.test.ts b/src/scripts/getRefreshToken.test.ts index e0b7479..95a3e54 100644 --- a/src/scripts/getRefreshToken.test.ts +++ b/src/scripts/getRefreshToken.test.ts @@ -1,7 +1,12 @@ import test from "ava"; -import { openBrowser, getRefreshToken } from "./getRefreshToken"; +import { openBrowser, getRefreshToken, isASCII } from "./getRefreshToken"; import { Response } from "node-fetch"; +test("isASCII", (t) => { + t.truthy(isASCII("nice456452413%")); + t.falsy(isASCII("£")); +}); + test("opens a browser window", (t) => { let nOpenBrowserCalls = 0; const testUrl = "http://test.com/"; @@ -16,11 +21,7 @@ test("opens a browser window", (t) => { }); test("getRefreshToken", async (t) => { - let nOpenBrowserCalls = 0; let nFetchCalls = 0; - let nGetInputCalls = 0; - const testUrl = - "https://accounts.google.com/o/oauth2/v2/auth?client_id=test_id&response_type=code&scope=openid%20email&access_type=offline&redirect_uri=urn:ietf:wg:oauth:2.0:oob"; const iapOptions = { clientId: "test_id", @@ -28,36 +29,22 @@ test("getRefreshToken", async (t) => { iapClientId: "not_used", }; - function mockOpenBrowser(url: string): void { - nOpenBrowserCalls += 1; - t.is(url, testUrl); - } - const mockFetch = (response: any) => async (): Promise => { nFetchCalls += 1; return new Response(JSON.stringify(response)); }; - async function mockUserInput(): Promise { - nGetInputCalls += 1; - return new Promise((resolve) => { - resolve("test_client_id"); - }); - } - const response = { refresh_token: "some_refresh_token", }; const refreshToken = await getRefreshToken( - mockOpenBrowser, - mockUserInput, + "?code=4/0Afge", mockFetch(response), iapOptions, + "localhost", ); - t.is(nOpenBrowserCalls, 1); t.is(nFetchCalls, 1); - t.is(nGetInputCalls, 1); t.is(refreshToken, "some_refresh_token"); }); diff --git a/src/scripts/getRefreshToken.ts b/src/scripts/getRefreshToken.ts index d864789..dd77b4c 100644 --- a/src/scripts/getRefreshToken.ts +++ b/src/scripts/getRefreshToken.ts @@ -1,43 +1,54 @@ import fetch from "node-fetch"; import { DesktopIAPOptions, Fetcher } from "../types"; +import url from "url"; export function openBrowser(browser: (url: string) => void, url: string): void { browser(url); } +export const isASCII = (value: string): boolean => /^[\x00-\x7F]+$/.test(value); export async function getRefreshToken( - browser: (url: string) => void, - inputHandler: (question: string) => Promise, + redirectUrl: string, fetcher: Fetcher = fetch, options: DesktopIAPOptions, + redirectUri: string, ): Promise { - const url = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${options.clientId}&response_type=code&scope=openid%20email&access_type=offline&redirect_uri=urn:ietf:wg:oauth:2.0:oob`; - - console.log("Open this URL if your browser didn't open automatically\n", url); - - openBrowser(browser, url); - - // await input from user - const loginToken = await inputHandler( - "Please input the token you received in the browser", - ); - - const oauthTokenBaseUrl = "https://www.googleapis.com/oauth2/v4/token"; - - const body = { - code: loginToken, - client_id: options.clientId, - client_secret: options.clientSecret, - redirect_uri: "urn:ietf:wg:oauth:2.0:oob", - grant_type: "authorization_code", - }; - - const request = await fetcher(oauthTokenBaseUrl, { - body: JSON.stringify(body), - method: "POST", - }); - - const response = await request.json(); - - return String(response["refresh_token"]); + const { code, error } = url.parse(redirectUrl, true).query; + if (error) { + // An error response e.g. error=access_denied + console.log("Error:" + error); + return ""; + } else { + if (!code || code === "") { + console.log("Authorization code is empty"); + return ""; + } + if (Array.isArray(code)) { + console.log("There is more then one authorization code in redirect url."); + return ""; + } + if (!isASCII(code)) { + console.log("Code contains not allowed characters: " + code); + return ""; + } + + const oauthTokenBaseUrl = "https://www.googleapis.com/oauth2/v4/token"; + + const body = { + code: code, + client_id: options.clientId, + client_secret: options.clientSecret, + redirect_uri: redirectUri, + grant_type: "authorization_code", + }; + + const request = await fetcher(oauthTokenBaseUrl, { + body: JSON.stringify(body), + method: "POST", + }); + + const response = await request.json(); + + return String(response["refresh_token"]); + } } diff --git a/src/scripts/index.ts b/src/scripts/index.ts index a2808e6..d983e5f 100644 --- a/src/scripts/index.ts +++ b/src/scripts/index.ts @@ -4,7 +4,8 @@ import { GetString } from "mamushi"; import open from "open"; import fetch from "node-fetch"; import { getRefreshToken } from "./getRefreshToken"; -import { handleUserInput } from "./inputHandler"; +import http from "http"; +import url from "url"; async function start(): Promise { const options = { @@ -12,19 +13,53 @@ async function start(): Promise { clientSecret: GetString("CLIENT_SECRET"), iapClientId: GetString("IAP_CLIENT_ID"), }; - const refreshToken = await getRefreshToken( - open, - handleUserInput, - fetch, - options, + const redirectUri = + GetString("GOOGLE_REDIRECT_URI") || "http://localhost:3000"; + const serverPort = Number(GetString("OAUTH_SERVER_PORT")) || 3000; + const authorizationUrl = url.resolve( + "https://accounts.google.com/o/oauth2/v2/auth", + url.format({ + query: { + client_id: options.clientId, + response_type: "code", + scope: "openid email", + access_type: "offline", + include_granted_scopes: true, + redirect_uri: redirectUri, + }, + }), ); - console.log(` -Refresh token generated. Please save it to your .env file: -\`\`\` -IAP_REFRESH_TOKEN=${refreshToken} -\`\`\` -`); + http + .createServer(async function (req, res) { + // Receive the callback from Google's OAuth 2.0 server. + if (req?.url?.startsWith("/?code")) { + // Handle the OAuth 2.0 server response + const refreshToken = await getRefreshToken( + req.url, + fetch, + options, + redirectUri, + ); + + const refreshTokenInfo = `\nRefresh token generated. Please save it to your .env file:\nIAP_REFRESH_TOKEN=${refreshToken}`; + res.end(refreshTokenInfo, () => { + console.log(refreshTokenInfo); + + // Ending server + process.kill(process.pid, "SIGTERM"); + }); + } + }) + .listen(serverPort, () => { + console.log(`🚀 Server ready at ${serverPort}`); + // open the browser to authorize url to start the workflow + console.log( + "Open this URL if your browser didn't open automatically\n", + authorizationUrl, + ); + open(authorizationUrl, { wait: false }).then((cp) => cp.unref()); + }); } -start(); +start().catch(console.error); diff --git a/src/scripts/inputHandler.ts b/src/scripts/inputHandler.ts deleted file mode 100644 index 65049c6..0000000 --- a/src/scripts/inputHandler.ts +++ /dev/null @@ -1,15 +0,0 @@ -import readline from "readline"; - -const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, -}); - -export function handleUserInput(question: string): Promise { - return new Promise((resolve) => { - rl.question(`${question}: `, (answer) => { - rl.close(); - resolve(answer); - }); - }); -} diff --git a/yarn.lock b/yarn.lock index 2c684f6..2d6a9eb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6852,6 +6852,11 @@ pump@^3.0.0: end-of-stream "^1.1.0" once "^1.3.1" +punycode@1.3.2: + version "1.3.2" + resolved "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" + integrity sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw== + punycode@^2.1.0: version "2.1.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" @@ -6874,6 +6879,11 @@ qs@6.11.0: dependencies: side-channel "^1.0.4" +querystring@0.2.0: + version "0.2.0" + resolved "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" + integrity sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g== + quick-lru@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-1.1.0.tgz#4360b17c61136ad38078397ff11416e186dcfbb8" @@ -8240,6 +8250,14 @@ url-parse-lax@^3.0.0: dependencies: prepend-http "^2.0.0" +url@^0.11.0: + version "0.11.0" + resolved "https://registry.npmjs.org/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" + integrity sha512-kbailJa29QrtXnxgq+DdCEGlbTeYM2eJUxsz6vjZavrCYPMIFHMKQmSKYAIuUK2i7hgPm28a8piX5NTUtM/LKQ== + dependencies: + punycode "1.3.2" + querystring "0.2.0" + util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"