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: oauth for chrome extension #4870

Merged
merged 19 commits into from
Apr 24, 2024
Merged
Show file tree
Hide file tree
Changes from 16 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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"class-transformer": "^0.5.1",
"clsx": "^1.2.1",
"cross-env": "^7.0.3",
"crypto-js": "^4.2.0",
"danger-plugin-todos": "^1.3.1",
"dataloader": "^2.2.2",
"date-fns": "^2.30.0",
Expand Down Expand Up @@ -229,6 +230,7 @@
"@types/bcrypt": "^5.0.0",
"@types/better-sqlite3": "^7.6.8",
"@types/bytes": "^3.1.1",
"@types/crypto-js": "^4.2.2",
"@types/deep-equal": "^1.0.1",
"@types/express": "^4.17.13",
"@types/graphql-fields": "^1.3.6",
Expand Down
88 changes: 86 additions & 2 deletions packages/twenty-chrome-extension/src/background/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import Crypto from 'crypto-js';
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 cannot use node:crypto to create a codeVerifier and codeChallenge since this project is outside of node scope, hence the different alternative crypto-js


import { openOptionsPage } from '~/background/utils/openOptionsPage';
import { exchangeAuthorizationCode } from '~/db/auth.db';
import { isDefined } from '~/utils/isDefined';

// Open options page programmatically in a new tab.
chrome.runtime.onInstalled.addListener((details) => {
Expand All @@ -18,7 +22,7 @@ chrome.runtime.onMessage.addListener((message, _, sendResponse) => {
switch (message.action) {
case 'getActiveTabUrl': // e.g. "https://linkedin.com/company/twenty/"
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs && tabs[0]) {
if (isDefined(tabs) && isDefined(tabs[0])) {
const activeTabUrl: string | undefined = tabs[0].url;
sendResponse({ url: activeTabUrl });
}
Expand All @@ -27,13 +31,93 @@ chrome.runtime.onMessage.addListener((message, _, sendResponse) => {
case 'openOptionsPage':
openOptionsPage();
break;
case 'CONNECT':
launchOAuth(({ status, message }) => {
sendResponse({ status, message });
});
break;
default:
break;
}

return true;
});

const generateRandomString = (length: number) => {
const charset =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
let result = '';
for (let i = 0; i < length; i++) {
result += charset.charAt(Math.floor(Math.random() * charset.length));
}
return result;
};

const generateCodeVerifierAndChallenge = () => {
const codeVerifier = generateRandomString(32);
const hash = Crypto.SHA256(codeVerifier);
const codeChallenge = hash
.toString(Crypto.enc.Base64)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');

return { codeVerifier, codeChallenge };
};

const launchOAuth = (
callback: ({ status, message }: { status: boolean; message: string }) => void,
) => {
const { codeVerifier, codeChallenge } = generateCodeVerifierAndChallenge();
const redirectUrl = chrome.identity.getRedirectURL();
chrome.identity
.launchWebAuthFlow({
url: `${
import.meta.env.VITE_FRONT_BASE_URL
}/authorize?clientId=chrome&codeChallenge=${codeChallenge}&redirectUrl=${redirectUrl}`,
interactive: true,
})
.then((responseUrl) => {
if (typeof responseUrl === 'string') {
const url = new URL(responseUrl);
const authorizationCode = url.searchParams.get(
'authorizationCode',
) as string;
exchangeAuthorizationCode({
authorizationCode,
codeVerifier,
}).then((tokens) => {
if (isDefined(tokens)) {
chrome.storage.local.set({
loginToken: tokens.loginToken,
});

chrome.storage.local.set({
accessToken: tokens.accessToken,
});

chrome.storage.local.set({
refreshToken: tokens.refreshToken,
});

callback({ status: true, message: '' });

chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (isDefined(tabs) && isDefined(tabs[0])) {
chrome.tabs.sendMessage(tabs[0].id ?? 0, {
action: 'AUTHENTICATED',
});
}
});
}
});
}
})
.catch((error) => {
callback({ status: false, message: error.message });
});
};

// Keep track of the tabs in which the "Add to Twenty" button has already been injected.
// Could be that the content script is executed at "https://linkedin.com/feed/", but is needed at "https://linkedin.com/in/mabdullahabaid/".
// However, since Linkedin is a SPA, the script would not be re-executed when you navigate to "https://linkedin.com/in/mabdullahabaid/" from a user action.
Expand All @@ -50,7 +134,7 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
tab.url?.match(/^https?:\/\/(?:www\.)?linkedin\.com\/in(?:\/\S+)?/);

if (changeInfo.status === 'complete' && tab.active) {
if (isDesiredRoute && !injectedTabs.has(tabId)) {
if (isDefined(isDesiredRoute) && !isDefined(injectedTabs.has(tabId))) {
chrome.tabs.sendMessage(tabId, { action: 'executeContentScript' });
injectedTabs.add(tabId);
} else if (!isDesiredRoute) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,10 @@ const createNewButton = (

// Handle the click event.
div.addEventListener('click', async () => {
const { apiKey } = await chrome.storage.local.get('apiKey');
const store = await chrome.storage.local.get();

// If an api key is not set, the options page opens up to allow the user to configure an api key.
if (!apiKey) {
if (!store.accessToken) {
chrome.runtime.sendMessage({ action: 'openOptionsPage' });
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import extractCompanyLinkedinLink from '~/contentScript/utils/extractCompanyLink
import extractDomain from '~/contentScript/utils/extractDomain';
import { createCompany, fetchCompany } from '~/db/company.db';
import { CompanyInput } from '~/db/types/company.types';
import { isDefined } from '~/utils/isDefined';

const insertButtonForCompany = async (): Promise<void> => {
// Select the element in which to create the button.
Expand All @@ -11,7 +12,7 @@ const insertButtonForCompany = async (): Promise<void> => {
);

// Create the button with desired callback funciton to execute upon click.
if (parentDiv) {
if (isDefined(parentDiv)) {
// Extract company-specific data from the DOM
const companyNameElement = document.querySelector(
'.org-top-card-summary__title',
Expand Down Expand Up @@ -68,7 +69,8 @@ const insertButtonForCompany = async (): Promise<void> => {
label: { eq: companyURL },
},
});
if (company) {

if (isDefined(company)) {
const savedCompany: HTMLDivElement = createNewButton(
'Saved',
async () => {},
Expand All @@ -88,7 +90,7 @@ const insertButtonForCompany = async (): Promise<void> => {
async () => {
const response = await createCompany(companyInputData);

if (response) {
if (isDefined(response)) {
newButtonCompany.textContent = 'Saved';
newButtonCompany.setAttribute('disabled', 'true');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import createNewButton from '~/contentScript/createButton';
import extractFirstAndLastName from '~/contentScript/utils/extractFirstAndLastName';
import { createPerson, fetchPerson } from '~/db/person.db';
import { PersonInput } from '~/db/types/person.types';
import { isDefined } from '~/utils/isDefined';

const insertButtonForPerson = async (): Promise<void> => {
// Select the element in which to create the button.
Expand All @@ -10,7 +11,7 @@ const insertButtonForPerson = async (): Promise<void> => {
);

// Create the button with desired callback funciton to execute upon click.
if (parentDiv) {
if (isDefined(parentDiv)) {
// Extract person-specific data from the DOM.
const personNameElement = document.querySelector('.text-heading-xlarge');

Expand Down Expand Up @@ -58,21 +59,19 @@ const insertButtonForPerson = async (): Promise<void> => {
});

// Remove last slash from the URL for consistency when saving usernames.
if (activeTabUrl.endsWith('/')) {
if (isDefined(activeTabUrl.endsWith('/'))) {
activeTabUrl = activeTabUrl.slice(0, -1);
}

personData.linkedinLink = { url: activeTabUrl, label: activeTabUrl };

const person = await fetchPerson({
name: {
firstName: { eq: firstName },
lastName: { eq: lastName },
},
linkedinLink: { url: { eq: activeTabUrl }, label: { eq: activeTabUrl } },
});

if (person) {
if (isDefined(person)) {
const savedPerson: HTMLDivElement = createNewButton(
'Saved',
async () => {},
Expand All @@ -92,7 +91,7 @@ const insertButtonForPerson = async (): Promise<void> => {
'Add to Twenty',
async () => {
const response = await createPerson(personData);
if (response) {
if (isDefined(response)) {
newButtonPerson.textContent = 'Saved';
newButtonPerson.setAttribute('disabled', 'true');

Expand Down
63 changes: 47 additions & 16 deletions packages/twenty-chrome-extension/src/contentScript/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import insertButtonForCompany from '~/contentScript/extractCompanyProfile';
import insertButtonForPerson from '~/contentScript/extractPersonProfile';
import { isDefined } from '~/utils/isDefined';

// Inject buttons into the DOM when SPA is reloaded on the resource url.
// e.g. reload the page when on https://www.linkedin.com/in/mabdullahabaid/
Expand All @@ -17,54 +18,84 @@ chrome.runtime.onMessage.addListener(async (message, _, sendResponse) => {
}

if (message.action === 'TOGGLE') {
toggle();
await toggle();
}

if (message.action === 'AUTHENTICATED') {
await authenticated();
}

sendResponse('Executing!');
});

const IFRAME_WIDTH = '400px';

const createIframe = () => {
const iframe = document.createElement('iframe');
iframe.style.background = 'lightgrey';
iframe.style.height = '100vh';
iframe.style.width = '400px';
iframe.style.width = IFRAME_WIDTH;
iframe.style.position = 'fixed';
iframe.style.top = '0px';
iframe.style.right = '-400px';
iframe.style.right = `-${IFRAME_WIDTH}`;
iframe.style.zIndex = '9000000000000000000';
iframe.style.transition = 'ease-in-out 0.3s';
return iframe;
};

const handleContentIframeLoadComplete = () => {
//If the pop-out window is already open then we replace loading iframe with our content iframe
if (loadingIframe.style.right === '0px') contentIframe.style.right = '0px';
loadingIframe.style.display = 'none';
if (optionsIframe.style.right === '0px') contentIframe.style.right = '0px';
optionsIframe.style.display = 'none';
contentIframe.style.display = 'block';
};

//Creating one iframe where we are loading our front end in the background
const contentIframe = createIframe();
contentIframe.style.display = 'none';
contentIframe.src = `${import.meta.env.VITE_FRONT_BASE_URL}`;
contentIframe.onload = handleContentIframeLoadComplete;

//Creating this iframe to show as a loading state until the above iframe loads completely
const loadingIframe = createIframe();
loadingIframe.src = chrome.runtime.getURL('loading.html');
chrome.storage.local.get().then((store) => {
if (isDefined(store.loginToken)) {
contentIframe.src = `${import.meta.env.VITE_FRONT_BASE_URL}`;
contentIframe.onload = handleContentIframeLoadComplete;
}
});

const optionsIframe = createIframe();
optionsIframe.src = chrome.runtime.getURL('options.html');

document.body.appendChild(loadingIframe);
document.body.appendChild(contentIframe);
document.body.appendChild(optionsIframe);

const toggleIframe = (iframe: HTMLIFrameElement) => {
if (iframe.style.right === '-400px' && iframe.style.display !== 'none') {
if (
iframe.style.right === `-${IFRAME_WIDTH}` &&
iframe.style.display !== 'none'
) {
iframe.style.right = '0px';
} else if (iframe.style.right === '0px' && iframe.style.display !== 'none') {
iframe.style.right = '-400px';
iframe.style.right = `-${IFRAME_WIDTH}`;
}
};

const toggle = () => {
toggleIframe(loadingIframe);
toggleIframe(contentIframe);
const toggle = async () => {
const store = await chrome.storage.local.get();
if (isDefined(store.accessToken)) {
toggleIframe(contentIframe);
} else {
toggleIframe(optionsIframe);
}
};

const authenticated = async () => {
const store = await chrome.storage.local.get();
if (isDefined(store.loginToken)) {
contentIframe.src = `${
import.meta.env.VITE_FRONT_BASE_URL
}/verify?loginToken=${store.loginToken.token}`;
contentIframe.onload = handleContentIframeLoadComplete;
toggleIframe(contentIframe);
} else {
toggleIframe(optionsIframe);
}
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
// Extract "https://www.linkedin.com/company/twenty/" from any of the following urls, which the user can visit while on the company page.

import { isDefined } from '~/utils/isDefined';

// "https://www.linkedin.com/company/twenty/" "https://www.linkedin.com/company/twenty/about/" "https://www.linkedin.com/company/twenty/people/".
const extractCompanyLinkedinLink = (activeTabUrl: string) => {
// Regular expression to match the company ID
Expand All @@ -7,7 +10,7 @@ const extractCompanyLinkedinLink = (activeTabUrl: string) => {
// Extract the company ID using the regex
const match = activeTabUrl.match(regex);

if (match && match[1]) {
if (isDefined(match) && isDefined(match[1])) {
const companyID = match[1];
const cleanCompanyURL = `https://www.linkedin.com/company/${companyID}`;
return cleanCompanyURL;
Expand Down
26 changes: 26 additions & 0 deletions packages/twenty-chrome-extension/src/db/auth.db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {
ExchangeAuthCodeInput,
ExchangeAuthCodeResponse,
Tokens,
} from '~/db/types/auth.types';
import { EXCHANGE_AUTHORIZATION_CODE } from '~/graphql/auth/mutations';
import { isDefined } from '~/utils/isDefined';
import { callMutation } from '~/utils/requestDb';

export const exchangeAuthorizationCode = async (
exchangeAuthCodeInput: ExchangeAuthCodeInput,
): Promise<Tokens | null> => {
const data = await callMutation<ExchangeAuthCodeResponse>(
EXCHANGE_AUTHORIZATION_CODE,
exchangeAuthCodeInput,
);
if (isDefined(data?.exchangeAuthorizationCode))
return data.exchangeAuthorizationCode;
else return null;
};

// export const RenewToken = async (appToken: string): Promise<Tokens | null> => {
// const data = await callQuery<Tokens>(RENEW_TOKEN, { appToken });
// if (isDefined(data)) return data;
// else return null;
// };
Comment on lines +22 to +26
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 its best to implement this in a follow up PR

Loading
Loading