feat: oauth for chrome extension (#4870)

Previously we had to create a separate API key to give access to chrome
extension so we can make calls to the DB. This PR includes logic to
initiate a oauth flow with PKCE method which redirects to the
`Authorise` screen to give access to server tokens.

Implemented in this PR- 
1. make `redirectUrl` a non-nullable parameter 
2. Add `NODE_ENV` to environment variable service
3. new env variable `CHROME_EXTENSION_REDIRECT_URL` on server side
4. strict checks for redirectUrl
5. try catch blocks on utils db query methods
6. refactor Apollo Client to handle `unauthorized` condition
7. input field to enter server url (for self-hosting)
8. state to show user if its already connected
9. show error if oauth flow is cancelled by user

Follow up PR -
Renew token logic

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Aditya Pimpalkar 2024-04-24 10:45:16 +01:00 committed by GitHub
parent 0a7f82333b
commit c63ee519ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 18564 additions and 15049 deletions

View File

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

View File

@ -1,2 +1,3 @@
VITE_SERVER_BASE_URL=https://api.twenty.com
VITE_FRONT_BASE_URL=https://app.twenty.com
VITE_MODE=production

View File

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

View File

@ -1,4 +1,7 @@
import Crypto from '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.
@ -27,6 +30,11 @@ chrome.runtime.onMessage.addListener((message, _, sendResponse) => {
case 'openOptionsPage':
openOptionsPage();
break;
case 'CONNECT':
launchOAuth(({ status, message }) => {
sendResponse({ status, message });
});
break;
default:
break;
}
@ -34,6 +42,81 @@ chrome.runtime.onMessage.addListener((message, _, sendResponse) => {
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+)?/) ||

View File

@ -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;
}

View File

@ -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/
@ -20,20 +21,26 @@ 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;
@ -41,33 +48,57 @@ const createIframe = () => {
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);
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);
}
};

View File

@ -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;
// };

View File

@ -13,27 +13,24 @@ 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
return data.companies.edges.length > 0
? isDefined(data.companies.edges[0].node)
? data.companies.edges[0].node
: null
: null;
}
return null;
} catch (error) {
return null;
}
};
export const createCompany = async (
company: CompanyInput,
): Promise<string | null> => {
try {
const data = await callMutation<CreateCompanyResponse>(CREATE_COMPANY, {
input: company,
});
@ -41,7 +38,4 @@ export const createCompany = async (
return data.createCompany.id;
}
return null;
} catch (error) {
return null;
}
};

View File

@ -13,25 +13,24 @@ import { callMutation, callQuery } from '../utils/requestDb';
export const fetchPerson = async (
personFilterData: PersonFilterInput,
): Promise<Person | null> => {
try {
const data = await callQuery<FindPersonResponse>(FIND_PERSON, {
filter: {
...personFilterData,
},
});
if (isDefined(data?.people.edges)) {
return data?.people.edges.length > 0 ? data?.people.edges[0].node : null;
return data.people.edges.length > 0
? isDefined(data.people.edges[0].node)
? data.people.edges[0].node
: null
: null;
}
return null;
} catch (error) {
return null;
}
};
export const createPerson = async (
person: PersonInput,
): Promise<string | null> => {
try {
const data = await callMutation<CreatePersonResponse>(CREATE_PERSON, {
input: person,
});
@ -39,7 +38,4 @@ export const createPerson = async (
return data.createPerson.id;
}
return null;
} catch (error) {
return null;
}
};

View File

@ -0,0 +1,20 @@
export type AuthToken = {
token: string;
expiresAt: Date;
};
export type ExchangeAuthCodeInput = {
authorizationCode: string;
codeVerifier?: string;
clientSecret?: string;
};
export type Tokens = {
loginToken: AuthToken;
accessToken: AuthToken;
refreshToken: AuthToken;
};
export type ExchangeAuthCodeResponse = {
exchangeAuthorizationCode: Tokens;
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
import { gql } from '@apollo/client';
export const EXCHANGE_AUTHORIZATION_CODE = gql`
mutation ExchangeAuthorizationCode(
$authorizationCode: String!
$codeVerifier: String
$clientSecret: String
) {
exchangeAuthorizationCode(
authorizationCode: $authorizationCode
codeVerifier: $codeVerifier
clientSecret: $clientSecret
) {
loginToken {
token
expiresAt
}
accessToken {
token
expiresAt
}
refreshToken {
token
expiresAt
}
}
}
`;

View File

@ -0,0 +1,20 @@
// import { gql } from '@apollo/client';
// export const RENEW_TOKEN = gql`
// query RenewToken($appToken: String!) {
// renewToken(appToken: $appToken) {
// loginToken {
// token
// expiresAt
// }
// accessToken {
// token
// expiresAt
// }
// refreshToken {
// token
// expiresAt
// }
// }
// }
// `;

View File

@ -2,6 +2,15 @@ import { defineManifest } from '@crxjs/vite-plugin';
import packageData from '../package.json';
const host_permissions =
process.env.VITE_MODE === 'development'
? ['https://www.linkedin.com/*', 'http://localhost:3001/*']
: ['https://www.linkedin.com/*'];
const external_sites =
process.env.VITE_MODE === 'development'
? [`https://app.twenty.com/*`, `http://localhost:3001/*`]
: [`https://app.twenty.com/*`];
export default defineManifest({
manifest_version: 3,
name: 'Twenty',
@ -32,11 +41,18 @@ export default defineManifest({
},
],
permissions: ['activeTab', 'storage'],
web_accessible_resources: [
{
resources: ['options.html'],
matches: ['https://www.linkedin.com/*'],
},
],
host_permissions: ['https://www.linkedin.com/*'],
permissions: ['activeTab', 'storage', 'identity'],
host_permissions: host_permissions,
externally_connectable: {
matches: [`https://app.twenty.com/*`, `http://localhost:3001/*`],
matches: external_sites,
},
});

View File

@ -1,21 +1,123 @@
import { useEffect, useState } from 'react';
import styled from '@emotion/styled';
import { ApiKeyForm } from '@/api-key/components/ApiKeyForm';
import { Loader } from '@/ui/display/loader/components/Loader';
import { MainButton } from '@/ui/input/button/MainButton';
import { TextInput } from '@/ui/input/components/TextInput';
const StyledContainer = styled.div`
const StyledWrapper = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.noisy};
display: flex;
flex-direction: column;
height: 100vh;
justify-content: center;
`;
const StyledContainer = styled.div`
background: ${({ theme }) => theme.background.primary};
width: 400px;
height: 350px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledActionContainer = styled.div`
align-items: center;
display: flex;
flex-direction: column;
gap: 10px;
justify-content: center;
width: 300px;
`;
const StyledLabel = styled.span`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-bottom: ${({ theme }) => theme.spacing(1)};
text-transform: uppercase;
`;
const Options = () => {
const [isAuthenticating, setIsAuthenticating] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [error, setError] = useState('');
const [serverBaseUrl, setServerBaseUrl] = useState(
import.meta.env.VITE_SERVER_BASE_URL,
);
const authenticate = () => {
setIsAuthenticating(true);
setError('');
chrome.runtime.sendMessage({ action: 'CONNECT' }, ({ status, message }) => {
if (status === true) {
setIsAuthenticated(true);
setIsAuthenticating(false);
chrome.storage.local.set({ isAuthenticated: true });
} else {
setError(message);
setIsAuthenticating(false);
}
});
};
useEffect(() => {
const getState = async () => {
const store = await chrome.storage.local.get();
if (store.serverBaseUrl !== '') {
setServerBaseUrl(store.serverBaseUrl);
} else {
setServerBaseUrl(import.meta.env.VITE_SERVER_BASE_URL);
}
if (store.isAuthenticated === true) setIsAuthenticated(true);
};
void getState();
}, []);
const handleBaseUrlChange = (value: string) => {
setServerBaseUrl(value);
setError('');
chrome.storage.local.set({ serverBaseUrl: value });
};
return (
<StyledWrapper>
<StyledContainer>
<ApiKeyForm />
<img src="/logo/32-32.svg" alt="twenty-logo" height={64} width={64} />
<StyledActionContainer>
<TextInput
label="Server URL"
value={serverBaseUrl}
onChange={handleBaseUrlChange}
placeholder="My base server URL"
error={error}
fullWidth
/>
{isAuthenticating ? (
<Loader />
) : isAuthenticated ? (
<StyledLabel>Connected!</StyledLabel>
) : (
<>
<MainButton
title="Connect your account"
onClick={() => authenticate()}
fullWidth
/>
<MainButton
title="Sign up"
variant="secondary"
onClick={() => window.open(`${serverBaseUrl}`, '_blank')}
fullWidth
/>
</>
)}
</StyledActionContainer>
</StyledContainer>
</StyledWrapper>
);
};

View File

@ -1,184 +0,0 @@
import { useEffect, useState } from 'react';
import styled from '@emotion/styled';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { Button } from '@/ui/input/button/Button';
import { TextInput } from '@/ui/input/components/TextInput';
import { Toggle } from '@/ui/input/components/Toggle';
import { isDefined } from '~/utils/isDefined';
const StyledContainer = styled.div<{ isToggleOn: boolean }>`
width: 400px;
margin: 0 auto;
background-color: ${({ theme }) => theme.background.primary};
padding: ${({ theme }) => theme.spacing(10)};
overflow: hidden;
transition: height 0.3s ease;
height: ${({ isToggleOn }) => (isToggleOn ? '450px' : '390px')};
max-height: ${({ isToggleOn }) => (isToggleOn ? '450px' : '390px')};
`;
const StyledHeader = styled.header`
margin-bottom: ${({ theme }) => theme.spacing(8)};
text-align: center;
`;
const StyledImgLogo = styled.img`
&:hover {
cursor: pointer;
}
`;
const StyledMain = styled.main`
margin-bottom: ${({ theme }) => theme.spacing(8)};
`;
const StyledFooter = styled.footer`
display: flex;
`;
const StyledTitleContainer = styled.div`
flex: 0 0 80%;
`;
const StyledToggleContainer = styled.div`
flex: 0 0 20%;
display: flex;
justify-content: flex-end;
`;
const StyledSection = styled.div<{ showSection: boolean }>`
transition:
max-height 0.3s ease,
opacity 0.3s ease;
overflow: hidden;
max-height: ${({ showSection }) => (showSection ? '200px' : '0')};
`;
const StyledButtonHorizontalContainer = styled.div`
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(4)};
width: 100%;
`;
export const ApiKeyForm = () => {
const [apiKey, setApiKey] = useState('');
const [route, setRoute] = useState('');
const [showSection, setShowSection] = useState(false);
useEffect(() => {
const getState = async () => {
const localStorage = await chrome.storage.local.get();
if (isDefined(localStorage.apiKey)) {
setApiKey(localStorage.apiKey);
}
if (isDefined(localStorage.serverBaseUrl)) {
setShowSection(true);
setRoute(localStorage.serverBaseUrl);
}
};
void getState();
}, []);
useEffect(() => {
if (import.meta.env.VITE_SERVER_BASE_URL !== route) {
chrome.storage.local.set({ serverBaseUrl: route });
} else {
chrome.storage.local.set({ serverBaseUrl: '' });
}
}, [route]);
const handleValidateKey = () => {
chrome.storage.local.set({ apiKey });
window.close();
};
const handleGenerateClick = () => {
window.open(`${import.meta.env.VITE_FRONT_BASE_URL}/settings/developers`);
};
const handleGoToTwenty = () => {
window.open(`${import.meta.env.VITE_FRONT_BASE_URL}`);
};
const handleToggle = () => {
setShowSection(!showSection);
};
return (
<StyledContainer isToggleOn={showSection}>
<StyledHeader>
<StyledImgLogo
src="/logo/32-32.svg"
alt="Twenty Logo"
onClick={handleGoToTwenty}
/>
</StyledHeader>
<StyledMain>
<H2Title
title="Connect your account"
description="Input your key to link the extension to your workspace."
/>
<TextInput
label="Api key"
value={apiKey}
onChange={setApiKey}
placeholder="My API key"
/>
<StyledButtonHorizontalContainer>
<Button
title="Generate a key"
fullWidth={true}
variant="primary"
accent="default"
size="small"
position="standalone"
soon={false}
disabled={false}
onClick={handleGenerateClick}
/>
<Button
title="Validate key"
fullWidth={true}
variant="primary"
accent="default"
size="small"
position="standalone"
soon={false}
disabled={apiKey === ''}
onClick={handleValidateKey}
/>
</StyledButtonHorizontalContainer>
</StyledMain>
<StyledFooter>
<StyledTitleContainer>
<H2Title
title="Custom route"
description="For developers interested in self-hosting or local testing of the extension."
/>
</StyledTitleContainer>
<StyledToggleContainer>
<Toggle value={showSection} onChange={handleToggle} />
</StyledToggleContainer>
</StyledFooter>
<StyledSection showSection={showSection}>
{showSection && (
<TextInput
label="Route"
value={route}
onChange={setRoute}
placeholder="My Route"
/>
)}
</StyledSection>
</StyledContainer>
);
};

View File

@ -0,0 +1,116 @@
import React from 'react';
import styled from '@emotion/styled';
type Variant = 'primary' | 'secondary';
type MainButtonProps = {
title: string;
fullWidth?: boolean;
width?: number;
variant?: Variant;
soon?: boolean;
} & React.ComponentProps<'button'>;
const StyledButton = styled.button<
Pick<MainButtonProps, 'fullWidth' | 'width' | 'variant'>
>`
align-items: center;
background: ${({ theme, variant, disabled }) => {
if (disabled === true) {
return theme.background.secondary;
}
switch (variant) {
case 'primary':
return theme.background.radialGradient;
case 'secondary':
return theme.background.primary;
default:
return theme.background.primary;
}
}};
border: 1px solid;
border-color: ${({ theme, disabled, variant }) => {
if (disabled === true) {
return theme.background.transparent.lighter;
}
switch (variant) {
case 'primary':
return theme.background.transparent.light;
case 'secondary':
return theme.border.color.medium;
default:
return theme.background.primary;
}
}};
border-radius: ${({ theme }) => theme.border.radius.md};
${({ theme, disabled }) => {
if (disabled === true) {
return '';
}
return `box-shadow: ${theme.boxShadow.light};`;
}}
color: ${({ theme, variant, disabled }) => {
if (disabled === true) {
return theme.font.color.light;
}
switch (variant) {
case 'primary':
return theme.grayScale.gray0;
case 'secondary':
return theme.font.color.primary;
default:
return theme.font.color.primary;
}
}};
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
display: flex;
flex-direction: row;
font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
gap: ${({ theme }) => theme.spacing(2)};
justify-content: center;
outline: none;
padding: ${({ theme }) => theme.spacing(2)} ${({ theme }) => theme.spacing(3)};
width: ${({ fullWidth, width }) =>
fullWidth ? '100%' : width ? `${width}px` : 'auto'};
${({ theme, variant }) => {
switch (variant) {
case 'secondary':
return `
&:hover {
background: ${theme.background.tertiary};
}
`;
default:
return `
&:hover {
background: ${theme.background.radialGradientHover}};
}
`;
}
}};
`;
export const MainButton = ({
title,
width,
fullWidth = false,
variant = 'primary',
type,
onClick,
disabled,
className,
}: MainButtonProps) => {
return (
<StyledButton
className={className}
{...{ disabled, fullWidth, width, onClick, type, variant }}
>
{title}
</StyledButton>
);
};

View File

@ -1,18 +1,75 @@
import { ApolloClient, InMemoryCache } from '@apollo/client';
import { ApolloClient, from, HttpLink, InMemoryCache } from '@apollo/client';
import { onError } from '@apollo/client/link/error';
import { isDefined } from '~/utils/isDefined';
const clearStore = () => {
chrome.storage.local.remove('loginToken');
chrome.storage.local.remove('accessToken');
chrome.storage.local.remove('refreshToken');
chrome.storage.local.set({ isAuthenticated: false });
};
const getApolloClient = async () => {
const { apiKey } = await chrome.storage.local.get('apiKey');
const { serverBaseUrl } = await chrome.storage.local.get('serverBaseUrl');
const store = await chrome.storage.local.get();
const serverUrl = `${
isDefined(store.serverBaseUrl)
? store.serverBaseUrl
: import.meta.env.VITE_SERVER_BASE_URL
}/graphql`;
return new ApolloClient({
cache: new InMemoryCache(),
uri: `${
serverBaseUrl ? serverBaseUrl : import.meta.env.VITE_SERVER_BASE_URL
}/graphql`,
headers: {
Authorization: `Bearer ${apiKey}`,
},
const errorLink = onError(({ graphQLErrors, networkError }) => {
if (isDefined(graphQLErrors)) {
for (const graphQLError of graphQLErrors) {
if (graphQLError.message === 'Unauthorized') {
//TODO: replace this with renewToken mutation
clearStore();
return;
}
switch (graphQLError?.extensions?.code) {
case 'UNAUTHENTICATED': {
//TODO: replace this with renewToken mutation
clearStore();
break;
}
default:
// eslint-disable-next-line no-console
console.error(
`[GraphQL error]: Message: ${graphQLError.message}, Location: ${
graphQLError.locations
? JSON.stringify(graphQLError.locations)
: graphQLError.locations
}, Path: ${graphQLError.path}`,
);
break;
}
}
}
if (isDefined(networkError)) {
// eslint-disable-next-line no-console
console.error(`[Network error]: ${networkError}`);
}
});
const httpLink = new HttpLink({
uri: serverUrl,
headers: isDefined(store.accessToken)
? {
Authorization: `Bearer ${store.accessToken.token}`,
}
: {},
});
const client = new ApolloClient({
cache: new InMemoryCache(),
link: from([errorLink, httpLink]),
});
return client;
};
export default getApolloClient;

View File

@ -1,31 +1,36 @@
import { OperationVariables } from '@apollo/client';
import { isUndefined } from '@sniptt/guards';
import { DocumentNode } from 'graphql';
import getApolloClient from '~/utils/apolloClient';
import { isDefined } from '~/utils/isDefined';
export const callQuery = async <T>(
query: DocumentNode,
variables?: OperationVariables,
): Promise<T | null> => {
try {
const client = await getApolloClient();
const { data } = await client.query<T>({ query, variables });
const { data, error } = await client.query<T>({ query, variables });
if (!isUndefined(error)) throw new Error(error.message);
return data ?? null;
if (isDefined(data)) return data;
else return null;
} catch (error) {
return null;
}
};
export const callMutation = async <T>(
mutation: DocumentNode,
variables?: OperationVariables,
): Promise<T | null> => {
try {
const client = await getApolloClient();
const { data, errors } = await client.mutate<T>({ mutation, variables });
const { data } = await client.mutate<T>({ mutation, variables });
if (!isUndefined(errors)) throw new Error(errors[0].message);
return data ?? null;
if (isDefined(data)) return data;
else return null;
} catch (error) {
return null;
}
};

View File

@ -3,6 +3,7 @@
interface ImportMetaEnv {
readonly VITE_SERVER_BASE_URL: string;
readonly VITE_FRONT_BASE_URL: string;
readonly VITE_MODE: string;
}
interface ImportMeta {

View File

@ -10,7 +10,7 @@ import { DefaultLayout } from '@/ui/layout/page/DefaultLayout';
import { PageTitle } from '@/ui/utilities/page-title/PageTitle';
import { CommandMenuEffect } from '~/effect-components/CommandMenuEffect';
import { GotoHotkeysEffect } from '~/effect-components/GotoHotkeysEffect';
import Authorize from '~/pages/auth/Authorize';
import { Authorize } from '~/pages/auth/Authorize';
import { ChooseYourPlan } from '~/pages/auth/ChooseYourPlan.tsx';
import { CreateProfile } from '~/pages/auth/CreateProfile';
import { CreateWorkspace } from '~/pages/auth/CreateWorkspace';

View File

@ -255,6 +255,7 @@ export type Mutation = {
deleteOneObject: Object;
deleteUser: User;
emailPasswordResetLink: EmailPasswordResetLink;
exchangeAuthorizationCode: ExchangeAuthCode;
generateApiKeyToken: ApiKeyToken;
generateJWT: AuthTokens;
generateTransientToken: TransientToken;
@ -282,7 +283,7 @@ export type MutationActivateWorkspaceArgs = {
export type MutationAuthorizeAppArgs = {
clientId: Scalars['String'];
codeChallenge?: InputMaybe<Scalars['String']>;
redirectUrl?: InputMaybe<Scalars['String']>;
redirectUrl: Scalars['String'];
};
@ -308,6 +309,13 @@ export type MutationEmailPasswordResetLinkArgs = {
};
export type MutationExchangeAuthorizationCodeArgs = {
authorizationCode: Scalars['String'];
clientSecret?: InputMaybe<Scalars['String']>;
codeVerifier?: InputMaybe<Scalars['String']>;
};
export type MutationGenerateApiKeyTokenArgs = {
apiKeyId: Scalars['String'];
expiresAt: Scalars['String'];
@ -429,7 +437,6 @@ export type Query = {
clientConfig: ClientConfig;
currentUser: User;
currentWorkspace: Workspace;
exchangeAuthorizationCode: ExchangeAuthCode;
findWorkspaceFromInviteHash: Workspace;
getProductPrices: ProductPricesEntity;
getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal;
@ -457,13 +464,6 @@ export type QueryCheckWorkspaceInviteHashIsValidArgs = {
};
export type QueryExchangeAuthorizationCodeArgs = {
authorizationCode: Scalars['String'];
clientSecret?: InputMaybe<Scalars['String']>;
codeVerifier?: InputMaybe<Scalars['String']>;
};
export type QueryFindWorkspaceFromInviteHashArgs = {
inviteHash: Scalars['String'];
};
@ -988,6 +988,7 @@ export type AuthTokensFragmentFragment = { __typename?: 'AuthTokenPair', accessT
export type AuthorizeAppMutationVariables = Exact<{
clientId: Scalars['String'];
codeChallenge: Scalars['String'];
redirectUrl: Scalars['String'];
}>;
@ -1517,8 +1518,12 @@ export type TrackMutationHookResult = ReturnType<typeof useTrackMutation>;
export type TrackMutationResult = Apollo.MutationResult<TrackMutation>;
export type TrackMutationOptions = Apollo.BaseMutationOptions<TrackMutation, TrackMutationVariables>;
export const AuthorizeAppDocument = gql`
mutation authorizeApp($clientId: String!, $codeChallenge: String!) {
authorizeApp(clientId: $clientId, codeChallenge: $codeChallenge) {
mutation authorizeApp($clientId: String!, $codeChallenge: String!, $redirectUrl: String!) {
authorizeApp(
clientId: $clientId
codeChallenge: $codeChallenge
redirectUrl: $redirectUrl
) {
redirectUrl
}
}
@ -1540,6 +1545,7 @@ export type AuthorizeAppMutationFn = Apollo.MutationFunction<AuthorizeAppMutatio
* variables: {
* clientId: // value for 'clientId'
* codeChallenge: // value for 'codeChallenge'
* redirectUrl: // value for 'redirectUrl'
* },
* });
*/

View File

@ -1,8 +1,16 @@
import { gql } from '@apollo/client';
export const AUTHORIZE_APP = gql`
mutation authorizeApp($clientId: String!, $codeChallenge: String!) {
authorizeApp(clientId: $clientId, codeChallenge: $codeChallenge) {
mutation authorizeApp(
$clientId: String!
$codeChallenge: String!
$redirectUrl: String!
) {
authorizeApp(
clientId: $clientId
codeChallenge: $codeChallenge
redirectUrl: $redirectUrl
) {
redirectUrl
}
}

View File

@ -52,7 +52,7 @@ const StyledButtonContainer = styled.div`
gap: 10px;
width: 100%;
`;
const Authorize = () => {
export const Authorize = () => {
const navigate = useNavigate();
const [searchParam] = useSearchParams();
//TODO: Replace with db call for registered third party apps
@ -66,6 +66,7 @@ const Authorize = () => {
const [app, setApp] = useState<App>();
const clientId = searchParam.get('clientId');
const codeChallenge = searchParam.get('codeChallenge');
const redirectUrl = searchParam.get('redirectUrl');
useEffect(() => {
const app = apps.find((app) => app.id === clientId);
@ -76,18 +77,20 @@ const Authorize = () => {
const [authorizeApp] = useAuthorizeAppMutation();
const handleAuthorize = async () => {
if (isDefined(clientId) && isDefined(codeChallenge)) {
if (
isDefined(clientId) &&
isDefined(codeChallenge) &&
isDefined(redirectUrl)
) {
await authorizeApp({
variables: {
clientId,
codeChallenge,
redirectUrl,
},
onCompleted: (data) => {
window.location.href = data.authorizeApp.redirectUrl;
},
onError: (error) => {
throw Error(error.message);
},
});
}
};
@ -124,5 +127,3 @@ const Authorize = () => {
</StyledContainer>
);
};
export default Authorize;

View File

@ -64,4 +64,4 @@ SIGN_IN_PREFILLED=true
# API_RATE_LIMITING_TTL=
# API_RATE_LIMITING_LIMIT=
# MUTATION_MAXIMUM_RECORD_AFFECTED=100
# CHROME_EXTENSION_REDIRECT_URL=https://bggmipldbceihilonnbpgoeclgbkblkp.chromiumapps.com
# CHROME_EXTENSION_REDIRECT_URL=https://bggmipldbceihilonnbpgoeclgbkblkp.chromiumapp.org

View File

@ -107,6 +107,17 @@ export class AuthResolver {
return { loginToken };
}
@Mutation(() => ExchangeAuthCode)
async exchangeAuthorizationCode(
@Args() exchangeAuthCodeInput: ExchangeAuthCodeInput,
) {
const tokens = await this.tokenService.verifyAuthorizationCode(
exchangeAuthCodeInput,
);
return tokens;
}
@Mutation(() => TransientToken)
@UseGuards(JwtAuthGuard)
async generateTransientToken(
@ -152,17 +163,6 @@ export class AuthResolver {
return authorizedApp;
}
@Query(() => ExchangeAuthCode)
async exchangeAuthorizationCode(
@Args() exchangeAuthCodeInput: ExchangeAuthCodeInput,
) {
const tokens = await this.tokenService.verifyAuthorizationCode(
exchangeAuthCodeInput,
);
return tokens;
}
@Mutation(() => AuthTokens)
@UseGuards(JwtAuthGuard)
async generateJWT(

View File

@ -14,8 +14,7 @@ export class AuthorizeAppInput {
@IsOptional()
codeChallenge?: string;
@Field(() => String, { nullable: true })
@Field(() => String)
@IsString()
@IsOptional()
redirectUrl?: string;
redirectUrl: string;
}

View File

@ -14,6 +14,8 @@ import { PasswordUpdateNotifyEmail } from 'twenty-emails';
import { addMilliseconds } from 'date-fns';
import ms from 'ms';
import { NodeEnvironment } from 'src/engine/integrations/environment/interfaces/node-environment.interface';
import { ChallengeInput } from 'src/engine/core-modules/auth/dto/challenge.input';
import { assert } from 'src/utils/assert';
import {
@ -197,9 +199,11 @@ export class AuthService {
{
id: 'chrome',
name: 'Chrome Extension',
redirectUrl: `${this.environmentService.get(
'CHROME_EXTENSION_REDIRECT_URL',
)}`,
redirectUrl:
this.environmentService.get('NODE_ENV') ===
NodeEnvironment.development
? authorizeAppInput.redirectUrl
: `${this.environmentService.get('CHROME_EXTENSION_REDIRECT_URL')}`,
},
];
@ -211,10 +215,14 @@ export class AuthService {
throw new NotFoundException(`Invalid client '${clientId}'`);
}
if (!client.redirectUrl && !authorizeAppInput.redirectUrl) {
if (!client.redirectUrl || !authorizeAppInput.redirectUrl) {
throw new NotFoundException(`redirectUrl not found for '${clientId}'`);
}
if (client.redirectUrl !== authorizeAppInput.redirectUrl) {
throw new ForbiddenException(`redirectUrl mismatch for '${clientId}'`);
}
const authorizationCode = crypto.randomBytes(42).toString('hex');
const expiresAt = addMilliseconds(new Date().getTime(), ms('5m'));

View File

@ -16,6 +16,7 @@ import {
} from 'class-validator';
import { EmailDriver } from 'src/engine/integrations/email/interfaces/email.interface';
import { NodeEnvironment } from 'src/engine/integrations/environment/interfaces/node-environment.interface';
import { assert } from 'src/utils/assert';
import { CastToStringArray } from 'src/engine/integrations/environment/decorators/cast-to-string-array.decorator';
@ -40,6 +41,10 @@ export class EnvironmentVariables {
@IsBoolean()
DEBUG_MODE = false;
@IsEnum(NodeEnvironment)
@IsString()
NODE_ENV: NodeEnvironment = NodeEnvironment.development;
@CastToPositiveNumber()
@IsOptional()
@IsNumber()

View File

@ -0,0 +1,4 @@
export enum NodeEnvironment {
development = 'development',
production = 'production',
}

View File

@ -16103,6 +16103,13 @@ __metadata:
languageName: node
linkType: hard
"@types/crypto-js@npm:^4.2.2":
version: 4.2.2
resolution: "@types/crypto-js@npm:4.2.2"
checksum: 760a2078f36f2a3a1089ef367b0d13229876adcf4bcd6e8824d00d9e9bfad8118dc7e6a3cc66322b083535e12be3a29044ccdc9603bfb12519ff61551a3322c6
languageName: node
linkType: hard
"@types/d3-color@npm:^2.0.0":
version: 2.0.6
resolution: "@types/d3-color@npm:2.0.6"
@ -23533,6 +23540,13 @@ __metadata:
languageName: node
linkType: hard
"crypto-js@npm:^4.2.0":
version: 4.2.0
resolution: "crypto-js@npm:4.2.0"
checksum: 8fbdf9d56f47aea0794ab87b0eb9833baf80b01a7c5c1b0edc7faf25f662fb69ab18dc2199e2afcac54670ff0cd9607a9045a3f7a80336cccd18d77a55b9fdf0
languageName: node
linkType: hard
"crypto-random-string@npm:^2.0.0":
version: 2.0.0
resolution: "crypto-random-string@npm:2.0.0"
@ -46478,6 +46492,7 @@ __metadata:
"@types/bcrypt": "npm:^5.0.0"
"@types/better-sqlite3": "npm:^7.6.8"
"@types/bytes": "npm:^3.1.1"
"@types/crypto-js": "npm:^4.2.2"
"@types/deep-equal": "npm:^1.0.1"
"@types/dompurify": "npm:^3.0.5"
"@types/express": "npm:^4.17.13"
@ -46534,6 +46549,7 @@ __metadata:
concurrently: "npm:^8.2.2"
cross-env: "npm:^7.0.3"
cross-var: "npm:^1.1.0"
crypto-js: "npm:^4.2.0"
danger: "npm:^11.3.0"
danger-plugin-todos: "npm:^1.3.1"
dataloader: "npm:^2.2.2"