diff --git a/README.md b/README.md index cf0a410be..28ace6e5c 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ For general configuration, [click here](docs_website/docs/configurations/general - OAuth - Google Cloud OAuth - Okta OAuth + - GitHub OAuth - LDAP ### Metastore diff --git a/docs_website/docs/integrations/add_auth.md b/docs_website/docs/integrations/add_auth.md index 75dc0ed47..824419c09 100644 --- a/docs_website/docs/integrations/add_auth.md +++ b/docs_website/docs/integrations/add_auth.md @@ -21,7 +21,7 @@ We will go through how to setup authentication with OAuth and LDAP starting from ### OAuth -Start by creating an OAuth client with the authentication provider (e.g. [Google](https://developers.google.com/identity/protocols/oauth2), [Okta](https://developer.okta.com/docs/guides/implement-oauth-for-okta/create-oauth-app/)). Make sure "http://localhost:10001/oauth2callback" is entered as allowed redirect uri. Once created, the next step is to change the querybook config by editing `containers/bundled_querybook_config.yaml`. Open that file and enter the following: +Start by creating an OAuth client with the authentication provider (e.g. [Google](https://developers.google.com/identity/protocols/oauth2), [Okta](https://developer.okta.com/docs/guides/implement-oauth-for-okta/create-oauth-app/), [GitHub](https://docs.github.com/en/developers/apps/building-oauth-apps/creating-an-oauth-app)). Make sure "http://localhost:10001/oauth2callback" is entered as allowed redirect uri. Once created, the next step is to change the querybook config by editing `containers/bundled_querybook_config.yaml`. Open that file and enter the following: #### Generic OAuth @@ -54,6 +54,15 @@ OKTA_BASE_URL: https://[Redacted].okta.com/oauth2 PUBLIC_URL: http://localhost:10001 ``` +#### GitHub + +```yaml +AUTH_BACKEND: app.auth.github_auth +PUBLIC_URL: http://localhost:10001 +OAUTH_CLIENT_ID: '---Redacted---' +OAUTH_CLIENT_SECRET: '---Redacted---' +``` + :::caution DO NOT CHECK IN `OAUTH_CLIENT_SECRET` to the codebase. The example above is for testing only. For production, please use environment variables to provide this value. ::: diff --git a/querybook/server/app/auth/github_auth.py b/querybook/server/app/auth/github_auth.py new file mode 100644 index 000000000..2b6e58ae3 --- /dev/null +++ b/querybook/server/app/auth/github_auth.py @@ -0,0 +1,57 @@ +import os +import requests +from app.auth.oauth_auth import OAuthLoginManager, OAUTH_CALLBACK_PATH +from env import QuerybookSettings + +from .utils import AuthenticationError + +# Relevant github issue https://github.com/singingwolfboy/flask-dance/issues/235 +os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1" + + +class GitHubLoginManager(OAuthLoginManager): + @property + def oauth_config(self): + return { + "callback_url": "{}{}".format( + QuerybookSettings.PUBLIC_URL, OAUTH_CALLBACK_PATH + ), + "client_id": QuerybookSettings.OAUTH_CLIENT_ID, + "client_secret": QuerybookSettings.OAUTH_CLIENT_SECRET, + "authorization_url": "https://github.com/login/oauth/authorize", + "token_url": "https://github.com/login/oauth/access_token", + "profile_url": "https://api.github.com/user", + "scope": "user:email", + } + + def _get_user_profile(self, access_token): + resp = requests.get( + self.oauth_config["profile_url"], + headers={"Authorization": "Bearer {}".format(access_token)}, + ) + if not resp or resp.status_code != 200: + raise AuthenticationError( + "Failed to fetch user profile, status ({0})".format( + resp.status if resp else "None" + ) + ) + return self._parse_user_profile(resp) + + def _parse_user_profile(self, resp): + user = resp.json() + + email = user["email"] + username = user["login"] + return username, email + + +login_manager = GitHubLoginManager() +ignore_paths = [OAUTH_CALLBACK_PATH] + + +def init_app(app): + login_manager.init_app(app) + + +def login(request): + return login_manager.login(request) diff --git a/querybook/server/app/auth/oauth_auth.py b/querybook/server/app/auth/oauth_auth.py index 54ec432c5..ae8706c20 100644 --- a/querybook/server/app/auth/oauth_auth.py +++ b/querybook/server/app/auth/oauth_auth.py @@ -32,10 +32,11 @@ def __init__(self): @property def oauth_session(self): + oauth_config = self.oauth_config return OAuth2Session( - self.oauth_config["client_id"], - scope=self.oauth_config["scope"], - redirect_uri=self.oauth_config["callback_url"], + oauth_config["client_id"], + scope=oauth_config["scope"], + redirect_uri=oauth_config["callback_url"], ) @property @@ -77,7 +78,6 @@ def oauth_callback(self): return f"

Error: {request.args.get('error')}

" code = request.args.get("code") - try: access_token = self._fetch_access_token(code) username, email = self._get_user_profile(access_token) @@ -85,7 +85,8 @@ def oauth_callback(self): flask_login.login_user( AuthUser(self.login_user(username, email, session=session)) ) - except AuthenticationError: + except AuthenticationError as e: + LOG.error("Failed authenticate oauth user", e) abort_unauthorized() next_url = "/" @@ -125,6 +126,9 @@ def _parse_user_profile(self, profile_response): @with_session def login_user(self, username, email, session=None): + if not username: + raise AuthenticationError("Username must not be empty!") + user = get_user_by_name(username, session=session) if not user: user = create_user( diff --git a/webpack.config.js b/webpack.config.js index a2afd23f3..69d0c2632 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -44,6 +44,10 @@ function getDevServerSettings(env) { target: QUERYBOOK_UPSTREAM, changeOrigin: true, }, + '/oauth2callback': { + target: QUERYBOOK_UPSTREAM, + changeOrigin: true, + }, }, publicPath: '/build/', onListening: (server) => {