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 1 commit
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
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
16 changes: 16 additions & 0 deletions querybook/server/datasources/github.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from app.datasource import register
from lib.github_integration.github_integration import get_github_manager
from typing import Dict


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


@register("/github/is_authenticated/", methods=["GET"])
def is_github_authenticated() -> str:
github_manager = get_github_manager()
is_authenticated = github_manager.get_github_token() is not None
return {"is_authenticated": is_authenticated}
zhangvi7 marked this conversation as resolved.
Show resolved Hide resolved
Empty file.
110 changes: 110 additions & 0 deletions querybook/server/lib/github_integration/github_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
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"


class GitHubIntegrationManager(GitHubLoginManager):
def __init__(self, additional_scopes: Optional[list] = None):
self.additional_scopes = additional_scopes or []
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}"
return config

def save_github_token(self, token: str) -> None:
flask_session["github_access_token"] = token
Copy link
Collaborator

Choose a reason for hiding this comment

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

let's define a const for the key github_access_token as it will be used by other module

LOG.debug("Saved GitHub token to session")
Copy link
Collaborator

Choose a reason for hiding this comment

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

will save to the user properties as well?

Copy link
Contributor Author

@zhangvi7 zhangvi7 Oct 25, 2024

Choose a reason for hiding this comment

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

Will handle refactoring to encrypt in db in later PR


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>
"""


def get_github_manager() -> GitHubIntegrationManager:
return GitHubIntegrationManager(additional_scopes=["repo"])
zhangvi7 marked this conversation as resolved.
Show resolved Hide resolved


@flask_app.route(GITHUB_OAUTH_CALLBACK)
def github_callback() -> str:
github_manager = get_github_manager()
return github_manager.github_integration_callback()
Copy link
Collaborator

Choose a reason for hiding this comment

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

would be better to just put this inside the datasources/github.py together with other github routes?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think we can leave here to keep OAuth flow logic together in GitHub manager, similar to gspread_exporter.py



# Test GitHub OAuth Flow
Copy link
Collaborator

Choose a reason for hiding this comment

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

let's not have the test code here. move to a separate test file

Copy link
Contributor Author

Choose a reason for hiding this comment

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

will remove

def main():
github_manager = GitHubIntegrationManager()
oauth_config = github_manager.oauth_config
client_id = oauth_config["client_id"]
client_secret = oauth_config["client_secret"]

from requests_oauthlib import OAuth2Session

github = OAuth2Session(client_id)
authorization_url, state = github.authorization_url(
oauth_config["authorization_url"]
)
print("Please go here and authorize,", authorization_url)

redirect_response = input("Paste the full redirect URL here:")
github.fetch_token(
oauth_config["token_url"],
client_secret=client_secret,
authorization_response=redirect_response,
)

user_profile = github.get(oauth_config["profile_url"]).json()
print(user_profile)


if __name__ == "__main__":
main()
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