Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add oauth flow for querybook github integration #1497

Merged
merged 9 commits into from
Oct 30, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions querybook/config/querybook_default_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ OAUTH_AUTHORIZATION_URL: ~
OAUTH_TOKEN_URL: ~
OAUTH_USER_PROFILE: ~

# --------------- GitHub Integration ---------------
GITHUB_CLIENT_ID: ~
GITHUB_CLIENT_SECRET: ~

# LDAP
LDAP_CONN: ~
LDAP_USER_DN: uid={},dc=example,dc=com
Expand Down
3 changes: 3 additions & 0 deletions querybook/server/datasources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
from . import comment
from . import survey
from . import query_transform
from . import github


# Keep this at the end of imports to make sure the plugin APIs override the default ones
try:
Expand Down Expand Up @@ -47,3 +49,4 @@
survey
query_transform
api_plugin
github
14 changes: 14 additions & 0 deletions querybook/server/datasources/github.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from app.datasource import register
from lib.github.github import github_manager
from typing import Dict


@register("/github/auth/", methods=["GET"])
def connect_github() -> Dict[str, str]:
return github_manager.initiate_github_integration()


@register("/github/is_authenticated/", methods=["GET"])
def is_github_authenticated() -> str:
is_authenticated = github_manager.get_github_token() is not None
return {"is_authenticated": is_authenticated}
4 changes: 4 additions & 0 deletions querybook/server/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ class QuerybookSettings(object):
OAUTH_USER_PROFILE = get_env_config("OAUTH_USER_PROFILE")
AZURE_TENANT_ID = get_env_config("AZURE_TENANT_ID")

# GitHub App settings for feature integration
GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID")
GITHUB_CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET")

LDAP_CONN = get_env_config("LDAP_CONN")
LDAP_USE_TLS = str(get_env_config("LDAP_USE_TLS")).lower() == "true"
LDAP_USE_BIND_USER = str(get_env_config("LDAP_USE_BIND_USER")).lower() == "true"
Expand Down
Empty file.
93 changes: 93 additions & 0 deletions querybook/server/lib/github/github.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import certifi
from flask import session as flask_session, request
from app.auth.github_auth import GitHubLoginManager
from env import QuerybookSettings
from lib.logger import get_logger
from app.flask_app import flask_app
from typing import Optional, Dict, Any

LOG = get_logger(__file__)

GITHUB_OAUTH_CALLBACK = "/github/oauth2callback"
GITHUB_ACCESS_TOKEN = "github_access_token"


class GitHubManager(GitHubLoginManager):
def __init__(
self,
additional_scopes: Optional[list] = None,
client_id: Optional[str] = None,
client_secret: Optional[str] = None,
):
self.additional_scopes = additional_scopes or []
self._client_id = client_id
self._client_secret = client_secret
super().__init__()

@property
def oauth_config(self) -> Dict[str, Any]:
config = super().oauth_config
config["scope"] = "user email " + " ".join(self.additional_scopes)
config[
"callback_url"
] = f"{QuerybookSettings.PUBLIC_URL}{GITHUB_OAUTH_CALLBACK}"
if self._client_id:
config["client_id"] = self._client_id
if self._client_secret:
config["client_secret"] = self._client_secret
return config

def save_github_token(self, token: str) -> None:
flask_session[GITHUB_ACCESS_TOKEN] = token
LOG.debug("Saved GitHub token to session")

def get_github_token(self) -> Optional[str]:
return flask_session.get(GITHUB_ACCESS_TOKEN)

def initiate_github_integration(self) -> Dict[str, str]:
github = self.oauth_session
authorization_url, state = github.authorization_url(
self.oauth_config["authorization_url"]
)
flask_session["oauth_state"] = state
return {"url": authorization_url}

def github_integration_callback(self) -> str:
try:
github = self.oauth_session
access_token = github.fetch_token(
self.oauth_config["token_url"],
client_secret=self.oauth_config["client_secret"],
authorization_response=request.url,
cert=certifi.where(),
)
self.save_github_token(access_token["access_token"])
return self.success_response()
except Exception as e:
LOG.error(f"Failed to obtain credentials: {e}")
return self.error_response(str(e))

def success_response(self) -> str:
return """
<p>Success! Please close the tab.</p>
<script>
window.opener.receiveChildMessage()
</script>
"""

def error_response(self, error_message: str) -> str:
return f"""
<p>Failed to obtain credentials, reason: {error_message}</p>
"""


github_manager = GitHubManager(
additional_scopes=["repo"],
client_id=QuerybookSettings.GITHUB_CLIENT_ID,
client_secret=QuerybookSettings.GITHUB_CLIENT_SECRET,
)


@flask_app.route(GITHUB_OAUTH_CALLBACK)
def github_callback() -> str:
return github_manager.github_integration_callback()
61 changes: 61 additions & 0 deletions querybook/webapp/components/DataDocGitHub/DataDocGitHubButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import React, { useCallback, useEffect, useState } from 'react';

import { GitHubResource } from 'resource/github';
import { IconButton } from 'ui/Button/IconButton';

import { GitHubModal } from './GitHubModal';

interface IProps {
docId: number;
}

export const DataDocGitHubButton: React.FunctionComponent<IProps> = ({
docId,
}) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);

useEffect(() => {
const checkAuthentication = async () => {
try {
const { data } = await GitHubResource.isAuthenticated();
setIsAuthenticated(data.is_authenticated);
} catch (error) {
console.error(
'Failed to check GitHub authentication status:',
error
);
}
};

checkAuthentication();
}, []);

const handleOpenModal = useCallback(() => {
setIsModalOpen(true);
}, []);

const handleCloseModal = useCallback(() => {
setIsModalOpen(false);
}, []);

return (
<>
<IconButton
icon="Github"
onClick={handleOpenModal}
tooltip="Connect to GitHub"
tooltipPos="left"
title="GitHub"
/>
{isModalOpen && (
<GitHubModal
docId={docId}
isAuthenticated={isAuthenticated}
setIsAuthenticated={setIsAuthenticated}
onClose={handleCloseModal}
/>
)}
</>
);
};
8 changes: 8 additions & 0 deletions querybook/webapp/components/DataDocGitHub/GitHub.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.GitHubAuth {
text-align: center;
padding: 20px;
}

.GitHubAuth-icon {
margin-bottom: 20px;
}
31 changes: 31 additions & 0 deletions querybook/webapp/components/DataDocGitHub/GitHubAuth.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from 'react';

import { Button } from 'ui/Button/Button';
import { Icon } from 'ui/Icon/Icon';
import { Message } from 'ui/Message/Message';

import './GitHub.scss';

interface IProps {
onAuthenticate: () => void;
}

export const GitHubAuth: React.FunctionComponent<IProps> = ({
onAuthenticate,
}) => (
<div className="GitHubAuth">
<Icon name="Github" size={64} className="GitHubAuth-icon" />
<Message
title="Connect to GitHub"
message="You currently do not have a GitHub provider linked to your account. Please authenticate to enable GitHub features on Querybook."
type="info"
iconSize={32}
/>
<Button
onClick={onAuthenticate}
title="Connect Now"
color="accent"
theme="fill"
/>
</div>
);
82 changes: 82 additions & 0 deletions querybook/webapp/components/DataDocGitHub/GitHubModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React, { useCallback, useState } from 'react';

import { ComponentType, ElementType } from 'const/analytics';
import { trackClick } from 'lib/analytics';
import { GitHubResource, IGitHubAuthResponse } from 'resource/github';
import { Message } from 'ui/Message/Message';
import { Modal } from 'ui/Modal/Modal';

import { GitHubAuth } from './GitHubAuth';

interface IProps {
docId: number;
isAuthenticated: boolean;
setIsAuthenticated: (isAuthenticated: boolean) => void;
onClose: () => void;
}

export const GitHubModal: React.FunctionComponent<IProps> = ({
docId,
isAuthenticated,
setIsAuthenticated,
onClose,
}) => {
const [errorMessage, setErrorMessage] = useState<string>(null);

const handleConnectGitHub = useCallback(async () => {
trackClick({
component: ComponentType.DATADOC_PAGE,
element: ElementType.GITHUB_CONNECT_BUTTON,
});

try {
const { data }: { data: IGitHubAuthResponse } =
await GitHubResource.connectGithub();
const url = data.url;
if (!url) {
throw new Error('Failed to get GitHub authentication URL');
}
const authWindow = window.open(url);

const receiveMessage = () => {
authWindow.close();
delete window.receiveChildMessage;
window.removeEventListener('message', receiveMessage, false);
setIsAuthenticated(true);
};
window.receiveChildMessage = receiveMessage;

// If the user closes the authentication window manually, clean up
const timer = setInterval(() => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why it needs a timer here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

detects if the user manually closed the authentication window to throw an error authentication process incomplete

if (authWindow.closed) {
clearInterval(timer);
window.removeEventListener(
'message',
receiveMessage,
false
);
throw new Error('Authentication process failed');
}
}, 1000);
} catch (error) {
console.error('GitHub authentication failed:', error);
setErrorMessage('GitHub authentication failed. Please try again.');
}
}, [setIsAuthenticated]);

return (
<Modal onHide={onClose} title="GitHub Integration">
<div className="GitHubModal-content">
{isAuthenticated ? (
<Message message="Connected to GitHub!" type="success" />
) : (
<GitHubAuth onAuthenticate={handleConnectGitHub} />
)}
{errorMessage && (
<Message message={errorMessage} type="error" />
)}
<button onClick={onClose}>Close</button>
</div>
</Modal>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useDispatch, useSelector } from 'react-redux';

import { DataDocBoardsButton } from 'components/DataDocBoardsButton/DataDocBoardsButton';
import { DataDocDAGExporterButton } from 'components/DataDocDAGExporter/DataDocDAGExporterButton';
import { DataDocGitHubButton } from 'components/DataDocGitHub/DataDocGitHubButton';
import { DataDocTemplateButton } from 'components/DataDocTemplateButton/DataDocTemplateButton';
import { DataDocUIGuide } from 'components/UIGuide/DataDocUIGuide';
import { ComponentType, ElementType } from 'const/analytics';
Expand Down Expand Up @@ -83,6 +84,8 @@ export const DataDocRightSidebar: React.FunctionComponent<IProps> = ({
<DataDocRunAllButton docId={dataDoc.id} />
);

const githubButtonDOM = <DataDocGitHubButton docId={dataDoc.id} />;

const buttonSection = (
<div className="DataDocRightSidebar-button-section vertical-space-between">
<div className="DataDocRightSidebar-button-section-top flex-column">
Expand Down Expand Up @@ -131,6 +134,7 @@ export const DataDocRightSidebar: React.FunctionComponent<IProps> = ({
</div>
<div className="DataDocRightSidebar-button-section-bottom flex-column mb8">
{runAllButtonDOM}
{githubButtonDOM}
{isEditable && exporterExists && (
<DataDocDAGExporterButton docId={dataDoc.id} />
)}
Expand Down
4 changes: 4 additions & 0 deletions querybook/webapp/const/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ export enum ElementType {
QUERY_GENERATION_REJECT_BUTTON = 'QUERY_GENERATION_REJECT_BUTTON',
QUERY_GENERATION_APPLY_BUTTON = 'QUERY_GENERATION_APPLY_BUTTON',
QUERY_GENERATION_APPLY_AND_RUN_BUTTON = 'QUERY_GENERATION_APPLY_AND_RUN_BUTTON',

// Github Integration
GITHUB_CONNECT_BUTTON = 'GITHUB_CONNECT_BUTTON',
GITHUB_LINK_BUTTON = 'GITHUB_LINK_BUTTON',
}

export interface EventData {
Expand Down
11 changes: 11 additions & 0 deletions querybook/webapp/resource/github.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import ds from 'lib/datasource';

export interface IGitHubAuthResponse {
url: string;
}

export const GitHubResource = {
connectGithub: () => ds.fetch<IGitHubAuthResponse>('/github/auth/'),
isAuthenticated: () =>
ds.fetch<{ is_authenticated: boolean }>('/github/is_authenticated/'),
};
2 changes: 2 additions & 0 deletions querybook/webapp/ui/Icon/LucideIcons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
FileText,
Filter,
FormInput,
Github,
GripVertical,
Hash,
HelpCircle,
Expand Down Expand Up @@ -167,6 +168,7 @@ const AllLucideIcons = {
FileText,
Filter,
FormInput,
Github,
GripVertical,
Hash,
HelpCircle,
Expand Down
Loading
Loading