From 372e327e75db027ae1b4f224c41d9626236c5a0f Mon Sep 17 00:00:00 2001 From: Chad Ostrowski Date: Mon, 7 Oct 2019 20:48:39 -0400 Subject: [PATCH 001/159] Create Authenticator app; update Projects We currently have a React app deployed at `https://tps.autark.xyz/tps-auth` which does basically what this Authenticator does. Here's how it works: 1. Projects opens an popup which loads the GitHub OAuth login page 2. We have [a GitHub OAuth App][1], where we provide an Authorization Callback URL. This is configured to that `tps.autark.xyz` page. 3. This Authorizor receives a `code` from GitHub, which it posts back to Projects using `opener.postMessage` 4. Projects uses this `code` to hit `https://tps.autark.xyz/authenticate`, which holds onto the private key for our GitHub OAuth App and can thus load an API `token` for the user, so Projects can start making requests as this user. This commit seeks to optimize three aspects of the current approach: 1. Code visibility/maintainability: rather than having a deployed app with seemingly no corresponding GitHub repository, this adds this key piece of our infrastructure directly to our main `open-enterprise` repo. It also simplifies this Authenticator, using a simple html page rather than a complex React app. 2. Design: rather than a sans-serif "loading" message, this gives a simple visual while the user is logged in, with a subtle technical status 3. Security: loading this app at a URL we own provides no additional security. Why? Because any app, not just Projects, could open a GitHub OAuth page and provide our CLIENT_ID. GitHub would then redirect back to our Authenticator, regardless of where it lives, and Authenticator would blindly pass the `code` it received back to `opener`. From there, a malicious app can easily POST that code to our `https://tps.autark.xyz/authenticate` endpoint and receive a token for the user. And then that malicious app could read a user's private repos and post comments as them, and it would look like these GitHub actions were mediated by us. Unfortunately, the solution in this commit fails to address this security concern. It tries to read `opener.location.pathname` so that it can check the identity of the calling app, before passing `code` back to it, but calling `opener.location.pathname` is forbidden, because this Authenticator lives at a different domain than Projects. Or rather, Projects is served from the same IPFS gateway, BUT since it is rendered within an iframe, the browser treats it as living at a different URL than the page in the popup. IPFS hash of this version: QmSYYEAFJiPyPd6JGtreu8tQZwEjARDxGZpSnTEvex8K4C [1]: https://github.com/organizations/AutarkLabs/settings/applications/953918 --- apps/authenticator/README.md | 29 +++++++++ apps/authenticator/deploy.sh | 26 ++++++++ apps/authenticator/package.json | 20 ++++++ apps/authenticator/public/index.html | 93 ++++++++++++++++++++++++++++ apps/projects/app/App.js | 12 +--- apps/projects/app/utils/github.js | 5 -- 6 files changed, 169 insertions(+), 16 deletions(-) create mode 100644 apps/authenticator/README.md create mode 100755 apps/authenticator/deploy.sh create mode 100644 apps/authenticator/package.json create mode 100644 apps/authenticator/public/index.html diff --git a/apps/authenticator/README.md b/apps/authenticator/README.md new file mode 100644 index 000000000..4b3e30d3b --- /dev/null +++ b/apps/authenticator/README.md @@ -0,0 +1,29 @@ +Simple OAuth Authenticator +========================== + +Here's how signing into GitHub from the Projects app works: + +1. From **Projects** (in iframe; served from `https://ipfs.eth.aragon.network/ipfs/`): calls `window.open` to open a popup. +2. The **popup** loads `https://github.com/login/oauth/authorize`; the user signs in; GitHub redirects to the Authorization Callback URL configured for [our GitHub OAuth App](https://github.com/organizations/AutarkLabs/settings/applications/953918), providing it a `code` (we can specify this using a `redirect_uri` parameter to GitHub, but this `redirect_uri` [must match-with-more-specificity](https://developer.github.com/apps/building-oauth-apps/authorizing-oauth-apps/#redirect-urls) the URL we already configured). +3. The **popup** loads our Authorization Callback URL, which calls `opener.postMessage({ code })` – this `opener` refers to the page that opened this popup. +4. The **Projects** app is that page (hopefully! see security note below); it listens for these posted messages, grabs the code, and hits a lightweight server process where our OAuth App's server-side key is stored, which uses the code to load an authentication token for that user. The Projects app saves that token so it can make calls to GitHub as the user. + +This mini app deals with Step 3 above. + + +Security Note +============= + +Note that any app, not just Projects, could open a popup that loads the OAuth page for our app, which dutifully redirects to this app. If this app blindly passed `code` to the calling page, it would allow any app to sign in users using our GitHub OAuth App. That's why this app checks the identity of `window.opener` against known versions of the Projects app. + + +Deployment +========== + +You will need [aragonCLI](https://hack.aragon.org/docs/cli-intro.html) installed globally: `npm i -g @aragon/cli` + +The `npm run deploy` script will walk you through the rest. Key concept: if you update this app, you have to update the settings on GitHub. We have not yet scripted this piece. So: + +1. Update the code +2. Run `npm run deploy`; this will spin up a local ipfs node, add `index.html`, and propogate it to the `ipfs.eth.aragon.network` gateway +3. Update GitHub settings with the new URL diff --git a/apps/authenticator/deploy.sh b/apps/authenticator/deploy.sh new file mode 100755 index 000000000..7b22eb1c9 --- /dev/null +++ b/apps/authenticator/deploy.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +DIR="$(dirname "${BASH_SOURCE[0]}")" + +blu=$'\e[1;34m' +end=$'\e[0m' + +echo starting ipfs... +aragon ipfs start & +sleep 10 + +HASH=`ipfs add -Q $DIR/public/index.html` +printf "\nAdded current index.html to local ipfs.\n\n" + +printf "Propogating to gateways...\n\n" +printf "This could take a while. While it runs, you may want to update the OAuth App settings on GitHub.\n" +printf " → Go to https://github.com/organizations/AutarkLabs/settings/applications/953918\n" +printf "%s\n" " → Set the ${blu}Authorization Callback URL${end} to ${blu}https://ipfs.eth.aragon.network/ipfs/$HASH${end}" +aragon ipfs propagate $HASH + +PROPOGATION_STATUS=$? + +printf "\nStopping ipfs...\n" +pkill ipfs + +exit $PROPOGATION_STATUS diff --git a/apps/authenticator/package.json b/apps/authenticator/package.json new file mode 100644 index 000000000..a93e5f185 --- /dev/null +++ b/apps/authenticator/package.json @@ -0,0 +1,20 @@ +{ + "name": "@tps/authenticator", + "version": "0.0.1", + "description": "static html that parses URL for `code` param and posts it to the opening window", + "scripts": { + "start": "http-server public", + "deploy": "./deploy.sh" + }, + "author": "", + "repository": { + "type": "git", + "url": "https://github.com/AutarkLabs/open-enterprise" + }, + "keywords": [], + "license": "GPL-3.0", + "private": true, + "devDependencies": { + "http-server": "^0.11.1" + } +} diff --git a/apps/authenticator/public/index.html b/apps/authenticator/public/index.html new file mode 100644 index 000000000..a69a0acf8 --- /dev/null +++ b/apps/authenticator/public/index.html @@ -0,0 +1,93 @@ + + + + + + Authenticating... + + + + + + +
authenticating...
+ + + diff --git a/apps/projects/app/App.js b/apps/projects/app/App.js index 9c38800df..1718ce535 100644 --- a/apps/projects/app/App.js +++ b/apps/projects/app/App.js @@ -24,7 +24,7 @@ import { } from './store/eventTypes' import { initApolloClient } from './utils/apollo-client' -import { getToken, getURLParam, githubPopup, STATUS } from './utils/github' +import { getToken, githubPopup, STATUS } from './utils/github' import Unauthorized from './components/Content/Unauthorized' import { LoadingAnimation } from './components/Shared' import { EmptyWrapper } from './components/Shared' @@ -61,16 +61,6 @@ const App = () => { setSelectedIssue(selectedIssueId) }, [selectedIssueId]) - useEffect(() => { - const code = getURLParam('code') - code && - window.opener.postMessage( - { from: 'popup', name: 'code', value: code }, - '*' - ) - window.close() - }) - const handlePopupMessage = async message => { if (message.data.from !== 'popup') return if (message.data.name === 'code') { diff --git a/apps/projects/app/utils/github.js b/apps/projects/app/utils/github.js index 773027659..95239d0e1 100644 --- a/apps/projects/app/utils/github.js +++ b/apps/projects/app/utils/github.js @@ -52,11 +52,6 @@ export const githubPopup = (popup, scope) => { return popup } -export const getURLParam = param => { - const searchParam = new URLSearchParams(window.location.search) - return searchParam.get(param) -} - /** * Sends an http request to the AUTH_URI with the auth code obtained from the oauth flow * @param {string} code From ec6043c02125156a65ed34bd2fa9b0c3299e866c Mon Sep 17 00:00:00 2001 From: Chad Ostrowski Date: Tue, 8 Oct 2019 16:17:32 -0400 Subject: [PATCH 002/159] Authenticator: Use postMessage origin to read URL Since Authenticator is not allowed to read `opener.location.pathname` directly, here's how this version works: 1. Projects opens a popup with the GitHub auth page 2. Our GitHub OAuth App is configured to redirect to the deployed Authenticator app (the index.html file here) 3. The Authenticator pings the `opener` (the Projects app) 4. Projects listens for this ping, and pings right back 5. Authenticator listens for pings from `opener`, and reads `origin` from these ping events. This DOES NOT WORK for Aragon apps. It would work for apps that are not loaded within an iframe, or for iframes that have [the `allow-same-origin` property set in their `sandbox` attribute][1] (changing this security setting is [not in the cards][2] for Aragon apps). But for regular iframes such as any Aragon app, when they send `postMessage` to the popup, the popup tries to read `message.origin`, and all it gets is `"null"`. IPFS hash for this version: QmaQFvQoGnWpsfLAumBJdSxV2zs9E6W5DPNB1fjg1QPxCu [1]: https://stackoverflow.com/questions/37838875/postmessage-from-a-sandboxed-iframe-to-the-main-window-origin-is-always-null [2]: https://github.com/aragon/aragon/blob/master/src/security/configuration.js#L22 --- apps/authenticator/public/index.html | 45 +++++++++++++++------------- apps/projects/app/App.js | 40 +++++++++++++++---------- 2 files changed, 49 insertions(+), 36 deletions(-) diff --git a/apps/authenticator/public/index.html b/apps/authenticator/public/index.html index a69a0acf8..c43e01f31 100644 --- a/apps/authenticator/public/index.html +++ b/apps/authenticator/public/index.html @@ -42,52 +42,57 @@
authenticating...
diff --git a/apps/projects/app/App.js b/apps/projects/app/App.js index 1718ce535..b6013b30d 100644 --- a/apps/projects/app/App.js +++ b/apps/projects/app/App.js @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react' +import React, { useCallback, useEffect, useState } from 'react' import { ApolloProvider } from 'react-apollo' import { useAragonApi, usePath } from './api-react' @@ -32,13 +32,14 @@ import { Error } from './components/Card' import { DecoratedReposProvider } from './context/DecoratedRepos' import usePathSegments from './hooks/usePathSegments' +let popupRef = null + const App = () => { const { api, appState } = useAragonApi() const [ , requestPath ] = usePath() const [ githubLoading, setGithubLoading ] = useState(false) const [ panel, setPanel ] = useState(null) const [ panelProps, setPanelProps ] = useState(null) - const [ popupRef, setPopupRef ] = useState(null) const { repos = [], @@ -61,15 +62,14 @@ const App = () => { setSelectedIssue(selectedIssueId) }, [selectedIssueId]) - const handlePopupMessage = async message => { - if (message.data.from !== 'popup') return - if (message.data.name === 'code') { - // TODO: Optimize the listeners lifecycle, ie: remove on unmount - window.removeEventListener('message', handlePopupMessage) + const handlePopupMessage = useCallback(async message => { + if (!popupRef) return + if (message.source !== popupRef) return - const code = message.data.value + switch (message.data.name) { + case 'code': try { - const token = await getToken(code) + const token = await getToken(message.data.code) setGithubLoading(false) api.emitTrigger(REQUESTED_GITHUB_TOKEN_SUCCESS, { status: STATUS.AUTHENTICATED, @@ -83,8 +83,21 @@ const App = () => { token: null, }) } + break + case 'ping': + // The popup cannot read `window.opener.location` directly because of + // same-origin policies. Instead, it pings this page, this page pings + // back, and the location info can be read from that ping. + popupRef.postMessage({ name: 'ping' }, '*') } - } + }, []) + + useEffect(() => { + window.addEventListener('message', handlePopupMessage) + return () => { + window.removeEventListener('message', handlePopupMessage) + } + }) const closePanel = () => { setPanel(null) @@ -97,13 +110,8 @@ const App = () => { } const handleGithubSignIn = () => { - // The popup is launched, its ref is checked and saved in the state in one step setGithubLoading(true) - - setPopupRef(githubPopup(popupRef)) - - // Listen for the github redirection with the auth-code encoded as url param - window.addEventListener('message', handlePopupMessage) + popupRef = githubPopup(popupRef) } const handleResolveLocalIdentity = address => { From 68124720441d218b8aa465d613168e865c1a67a0 Mon Sep 17 00:00:00 2001 From: Chad Ostrowski Date: Tue, 8 Oct 2019 16:06:39 -0400 Subject: [PATCH 003/159] Try to OAuth via iframe This does not work, because the GitHub login page forbids being loaded within an iframe using a [`frame-ancestors 'none'` Content Security Policy][1] IPFS hash of this version: QmbC4CupvRo3JnducA8vdg9VmTMNCuUWs4eQiTACgLtxru [1]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/frame-ancestors --- apps/authenticator/public/index.html | 18 +++---- apps/projects/app/App.js | 55 ++------------------- apps/projects/app/GithubSignin.js | 72 ++++++++++++++++++++++++++++ apps/projects/app/utils/github.js | 4 +- 4 files changed, 87 insertions(+), 62 deletions(-) create mode 100644 apps/projects/app/GithubSignin.js diff --git a/apps/authenticator/public/index.html b/apps/authenticator/public/index.html index c43e01f31..172915dce 100644 --- a/apps/authenticator/public/index.html +++ b/apps/authenticator/public/index.html @@ -42,8 +42,8 @@
authenticating...
diff --git a/apps/projects/app/App.js b/apps/projects/app/App.js index b6013b30d..4c439e8d3 100644 --- a/apps/projects/app/App.js +++ b/apps/projects/app/App.js @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react' +import React, { useEffect, useState } from 'react' import { ApolloProvider } from 'react-apollo' import { useAragonApi, usePath } from './api-react' @@ -18,21 +18,14 @@ import IssueDetail from './components/Content/IssueDetail' import { PanelManager, PanelContext, usePanelManagement } from './components/Panel' import { IdentityProvider } from '../../../shared/identity' -import { - REQUESTED_GITHUB_TOKEN_SUCCESS, - REQUESTED_GITHUB_TOKEN_FAILURE, -} from './store/eventTypes' import { initApolloClient } from './utils/apollo-client' -import { getToken, githubPopup, STATUS } from './utils/github' +import { STATUS } from './utils/github' import Unauthorized from './components/Content/Unauthorized' -import { LoadingAnimation } from './components/Shared' -import { EmptyWrapper } from './components/Shared' import { Error } from './components/Card' import { DecoratedReposProvider } from './context/DecoratedRepos' import usePathSegments from './hooks/usePathSegments' - -let popupRef = null +import GithubSignin from './GithubSignin' const App = () => { const { api, appState } = useAragonApi() @@ -62,43 +55,6 @@ const App = () => { setSelectedIssue(selectedIssueId) }, [selectedIssueId]) - const handlePopupMessage = useCallback(async message => { - if (!popupRef) return - if (message.source !== popupRef) return - - switch (message.data.name) { - case 'code': - try { - const token = await getToken(message.data.code) - setGithubLoading(false) - api.emitTrigger(REQUESTED_GITHUB_TOKEN_SUCCESS, { - status: STATUS.AUTHENTICATED, - token - }) - - } catch (err) { - setGithubLoading(false) - api.emitTrigger(REQUESTED_GITHUB_TOKEN_FAILURE, { - status: STATUS.FAILED, - token: null, - }) - } - break - case 'ping': - // The popup cannot read `window.opener.location` directly because of - // same-origin policies. Instead, it pings this page, this page pings - // back, and the location info can be read from that ping. - popupRef.postMessage({ name: 'ping' }, '*') - } - }, []) - - useEffect(() => { - window.addEventListener('message', handlePopupMessage) - return () => { - window.removeEventListener('message', handlePopupMessage) - } - }) - const closePanel = () => { setPanel(null) setPanelProps(null) @@ -111,7 +67,6 @@ const App = () => { const handleGithubSignIn = () => { setGithubLoading(true) - popupRef = githubPopup(popupRef) } const handleResolveLocalIdentity = address => { @@ -127,9 +82,7 @@ const App = () => { const noop = () => {} if (githubLoading) { return ( - - - + ) } else if (github.status === STATUS.INITIAL) { return ( diff --git a/apps/projects/app/GithubSignin.js b/apps/projects/app/GithubSignin.js new file mode 100644 index 000000000..71fd94b30 --- /dev/null +++ b/apps/projects/app/GithubSignin.js @@ -0,0 +1,72 @@ +import React, { useCallback, useEffect, useRef } from 'react' +import PropTypes from 'prop-types' + +import { useAragonApi } from './api-react' +import { + REQUESTED_GITHUB_TOKEN_SUCCESS, + REQUESTED_GITHUB_TOKEN_FAILURE, +} from './store/eventTypes' + +import { CLIENT_ID, getToken, GITHUB_URI, STATUS } from './utils/github' + +const GithubSignin = ({ setGithubLoading }) => { + const { api } = useAragonApi() + + const handleIframeMessage = useCallback(async message => { + if (!iframeRef.current) return + console.log(message, iframeRef, message.source === iframeRef) + if (message.source !== iframeRef) return + + switch (message.data.name) { + case 'code': + try { + const token = await getToken(message.data.code) + setGithubLoading(false) + api.emitTrigger(REQUESTED_GITHUB_TOKEN_SUCCESS, { + status: STATUS.AUTHENTICATED, + token + }) + + } catch (err) { + setGithubLoading(false) + api.emitTrigger(REQUESTED_GITHUB_TOKEN_FAILURE, { + status: STATUS.FAILED, + token: null, + }) + } + break + case 'ping': + // The popup cannot read `window.opener.location` directly because of + // same-origin policies. Instead, it pings this page, this page pings + // back, and the location info can be read from that ping. + iframeRef.current.postMessage({ name: 'ping' }, '*') + } + }, []) + + useEffect(() => { + window.addEventListener('message', handleIframeMessage) + return () => { + window.removeEventListener('message', handleIframeMessage) + } + }) + + const iframeRef = useRef(null) + + return ( +