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 all 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 @@ -230,6 +231,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
3 changes: 2 additions & 1 deletion packages/twenty-chrome-extension/.env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
VITE_SERVER_BASE_URL=https://api.twenty.com
VITE_FRONT_BASE_URL=https://app.twenty.com
VITE_FRONT_BASE_URL=https://app.twenty.com
VITE_MODE=production
14 changes: 7 additions & 7 deletions packages/twenty-chrome-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@
"type": "module",
"scripts": {
"nx": "NX_DEFAULT_PROJECT=twenty-chrome-extension node ../../node_modules/nx/bin/nx.js",
"clean": "npx rimraf ./dist",
"start": "yarn clean && npx vite",
"build": "yarn clean && npx tsc && npx vite build",
"lint": "npx eslint . --report-unused-disable-directives --max-warnings 0 --config .eslintrc.cjs",
"graphql:generate": "npx graphql-codegen",
"fmt": "npx prettier --check \"src/**/*.ts\" \"src/**/*.tsx\"",
"fmt:fix": "npx prettier --cache --write \"src/**/*.ts\" \"src/**/*.tsx\""
"clean": "rimraf ./dist",
"start": "yarn clean && VITE_MODE=development vite",
"build": "yarn clean && tsc && vite build",
"lint": "eslint . --report-unused-disable-directives --max-warnings 0 --config .eslintrc.cjs",
"graphql:generate": "graphql-codegen",
"fmt": "prettier --check \"src/**/*.ts\" \"src/**/*.tsx\"",
"fmt:fix": "prettier --cache --write \"src/**/*.ts\" \"src/**/*.tsx\""
},
"dependencies": {
"@types/chrome": "^0.0.256"
Expand Down
83 changes: 83 additions & 0 deletions packages/twenty-chrome-extension/src/background/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
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.
Expand Down Expand Up @@ -27,13 +30,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 });
});
};

chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
const isDesiredRoute =
tab.url?.match(/^https?:\/\/(?:www\.)?linkedin\.com\/company(?:\/\S+)?/) ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,13 @@ export const createDefaultButton = (
Object.assign(div.style, divStyles);
});

// Handle the click event.
div.addEventListener('click', async (e) => {
e.preventDefault();
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 (!store.apiKey) {
if (!store.accessToken) {
chrome.runtime.sendMessage({ action: 'openOptionsPage' });
return;
}
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 @@ -20,54 +21,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);
}
};
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

42 changes: 18 additions & 24 deletions packages/twenty-chrome-extension/src/db/company.db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,35 +13,29 @@ import { callMutation, callQuery } from '../utils/requestDb';
export const fetchCompany = async (
companyfilerInput: CompanyFilterInput,
): Promise<Company | null> => {
try {
const data = await callQuery<FindCompanyResponse>(FIND_COMPANY, {
filter: {
...companyfilerInput,
},
});
if (isDefined(data?.companies.edges)) {
return data?.companies.edges.length > 0
? data?.companies.edges[0].node
: null;
}
return null;
} catch (error) {
return null;
const data = await callQuery<FindCompanyResponse>(FIND_COMPANY, {
filter: {
...companyfilerInput,
},
});
if (isDefined(data?.companies.edges)) {
return data.companies.edges.length > 0
? isDefined(data.companies.edges[0].node)
? data.companies.edges[0].node
: null
: null;
}
return null;
};

export const createCompany = async (
company: CompanyInput,
): Promise<string | null> => {
try {
const data = await callMutation<CreateCompanyResponse>(CREATE_COMPANY, {
input: company,
});
if (isDefined(data)) {
return data.createCompany.id;
}
return null;
} catch (error) {
return null;
const data = await callMutation<CreateCompanyResponse>(CREATE_COMPANY, {
input: company,
});
if (isDefined(data)) {
return data.createCompany.id;
}
return null;
};
Loading
Loading