Compare commits

...

3 Commits

Author SHA1 Message Date
Félix Malfait f27ac7be16 feat: app registration UI, OAuth consent screen, and CLI integration
Frontend:
- SettingsAppRegistrationDetails page with general info, OAuth credentials,
  redirect URI management (with URL validation), server variables, and
  install stats sections
- Developer tab replacing the Create tab on Settings > Applications
- OAuth consent screen (Authorize page) updated for AppRegistration
- GraphQL queries, mutations, and fragments for app registrations
- SettingsAdminTableCard copy-on-click enhancement
- New route and navigation item for app registration details

CLI/SDK:
- RegisterAppOrchestratorStep for automatic server-side registration
- API service methods for app registration CRUD
- Config service persists clientId and clientSecret locally
- Dev mode orchestrator wires registration into the dev flow

Made-with: Cursor
2026-02-26 11:01:10 +01:00
Félix Malfait fd3fe6aa2d feat(server): OAuth 2.0 server and application sync improvements
Add OAuth 2.0 authorization server capabilities and improve application
sync to automatically keep AppRegistration metadata up to date.

OAuth server:
- Authorization Code flow with PKCE support
- Client Credentials flow for machine-to-machine auth
- Refresh Token flow for token renewal
- All flows issue application-scoped tokens via ApplicationTokenService
- OpenID Connect discovery endpoint (/.well-known/openid-configuration)
- REST token endpoint with proper validation decorators
- JWT strategy updated for optional user context in app tokens

Application sync:
- Auto-create AppRegistration during manifest sync if missing
- Sync name, description, logoUrl, author, websiteUrl, termsUrl from manifest
- Sync server variable schemas from manifest declarations
- Flatten marketplaceData onto ApplicationManifest (remove nesting)

Made-with: Cursor
2026-02-26 10:57:25 +01:00
Félix Malfait d025337ddb feat(server): add AppRegistration entity and server-level secrets
Introduce a server-level AppRegistration entity for managing application
identity, OAuth client credentials, and server-scoped encrypted variables.

- AppRegistration entity with clientId, clientSecretHash, redirectUris, scopes
- AppRegistrationVariable entity with AES-256-GCM encryption for secrets
- Full CRUD resolver secured with SettingsPermissionGuard
- Universal identifier guard service for collision detection
- Variable resolution service for runtime secret access
- Database migration creating both tables with proper indexes/FKs
- ApplicationEntity gains appRegistrationId FK (ON DELETE SET NULL)
- Shared types: ServerVariables, updated ApplicationManifest
- Unit tests for encryption, identifier guard, and service logic

Made-with: Cursor
2026-02-26 10:56:45 +01:00
62 changed files with 3759 additions and 336 deletions
@@ -167,6 +167,10 @@ describe('copyBaseApplicationProject', () => {
expect(appConfigContent).toContain(
'defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER',
);
// Verify it includes serverVariables example
expect(appConfigContent).toContain('serverVariables:');
expect(appConfigContent).toContain('EXAMPLE_API_KEY');
});
it('should create default-role.ts with defineRole and correct values', async () => {
@@ -485,6 +485,13 @@ export default defineApplication({
description: '${description ?? ''}',
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
serverVariables: {
EXAMPLE_API_KEY: {
description: 'An example API key for a third-party service',
isSecret: true,
isRequired: false,
},
},
});
`;
@@ -170,6 +170,14 @@ const SettingsAvailableApplicationDetails = lazy(() =>
})),
);
const SettingsAppRegistrationDetails = lazy(() =>
import('~/pages/settings/applications/SettingsAppRegistrationDetails').then(
(module) => ({
default: module.SettingsAppRegistrationDetails,
}),
),
);
const SettingsAgentForm = lazy(() =>
import('~/pages/settings/ai/SettingsAgentForm').then((module) => ({
default: module.SettingsAgentForm,
@@ -622,6 +630,10 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
path={SettingsPath.AvailableApplicationDetail}
element={<SettingsAvailableApplicationDetails />}
/>
<Route
path={SettingsPath.AppRegistrationDetail}
element={<SettingsAppRegistrationDetails />}
/>
<Route
path={SettingsPath.ApplicationLogicFunctionDetail}
element={<SettingsLogicFunctionDetail />}
@@ -48,6 +48,7 @@ type TableItem = {
Icon?: IconComponent;
label: string;
value: string | number | React.ReactNode;
onClick?: () => void;
};
type SettingsAdminTableCardProps = {
@@ -82,7 +83,11 @@ export const SettingsAdminTableCard = ({
{item.Icon && <item.Icon size={theme.icon.size.md} />}
<span>{item.label}</span>
</StyledTableCellLabel>
<StyledTableCellValue align={valueAlign}>
<StyledTableCellValue
align={valueAlign}
onClick={item.onClick}
style={item.onClick ? { cursor: 'pointer' } : undefined}
>
{item.value}
</StyledTableCellValue>
</StyledTableRow>
@@ -0,0 +1,13 @@
import { gql } from '@apollo/client';
export const FIND_APP_REGISTRATION_BY_CLIENT_ID = gql`
query FindAppRegistrationByClientId($clientId: String!) {
findAppRegistrationByClientId(clientId: $clientId) {
id
name
scopes
websiteUrl
logoUrl
}
}
`;
@@ -0,0 +1,44 @@
import { gql } from '@apollo/client';
import { APP_REGISTRATION_FRAGMENT } from '@/settings/app-registrations/graphql/queries';
export const UPDATE_APP_REGISTRATION = gql`
mutation UpdateAppRegistration($input: UpdateAppRegistrationInput!) {
updateAppRegistration(input: $input) {
...AppRegistrationFragment
}
}
${APP_REGISTRATION_FRAGMENT}
`;
export const DELETE_APP_REGISTRATION = gql`
mutation DeleteAppRegistration($id: String!) {
deleteAppRegistration(id: $id)
}
`;
export const ROTATE_APP_REGISTRATION_CLIENT_SECRET = gql`
mutation RotateAppRegistrationClientSecret($id: String!) {
rotateAppRegistrationClientSecret(id: $id) {
clientSecret
}
}
`;
export const UPDATE_APP_REGISTRATION_VARIABLE = gql`
mutation UpdateAppRegistrationVariable(
$input: UpdateAppRegistrationVariableInput!
) {
updateAppRegistrationVariable(input: $input) {
id
key
description
isSecret
isRequired
isFilled
createdAt
updatedAt
}
}
`;
@@ -0,0 +1,65 @@
import { gql } from '@apollo/client';
export const APP_REGISTRATION_FRAGMENT = gql`
fragment AppRegistrationFragment on AppRegistration {
id
universalIdentifier
name
description
logoUrl
author
clientId
redirectUris
scopes
websiteUrl
termsUrl
createdAt
updatedAt
}
`;
export const FIND_MANY_APP_REGISTRATIONS = gql`
query FindManyAppRegistrations {
findManyAppRegistrations {
...AppRegistrationFragment
}
}
${APP_REGISTRATION_FRAGMENT}
`;
export const FIND_ONE_APP_REGISTRATION = gql`
query FindOneAppRegistration($id: String!) {
findOneAppRegistration(id: $id) {
...AppRegistrationFragment
}
}
${APP_REGISTRATION_FRAGMENT}
`;
export const FIND_APP_REGISTRATION_STATS = gql`
query FindAppRegistrationStats($id: String!) {
findAppRegistrationStats(id: $id) {
activeInstalls
latestVersion
versionDistribution {
version
count
}
}
}
`;
export const FIND_APP_REGISTRATION_VARIABLES = gql`
query FindAppRegistrationVariables($appRegistrationId: String!) {
findAppRegistrationVariables(appRegistrationId: $appRegistrationId) {
id
key
description
isSecret
isRequired
isFilled
createdAt
updatedAt
}
}
`;
@@ -8,10 +8,10 @@ import { supportChatState } from '@/client-config/states/supportChatState';
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
import { type NavigationDrawerItemIndentationLevel } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import {
IconApi,
// IconApps, // TODO: Re-enable when integrations page is ready
@@ -173,7 +173,7 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
// isHidden: !permissionMap[PermissionFlagType.API_KEYS_AND_WEBHOOKS],
// },
{
label: t`Applications`,
label: t`Apps`,
path: SettingsPath.Applications,
Icon: IconPlug,
isHidden:
@@ -1,18 +1,17 @@
import { FIND_APP_REGISTRATION_BY_CLIENT_ID } from '@/settings/app-registrations/graphql/findAppRegistrationByClientId';
import styled from '@emotion/styled';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { AppPath } from 'twenty-shared/types';
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { Trans, useLingui } from '@lingui/react/macro';
import { useQuery } from '@apollo/client';
import { isDefined } from 'twenty-shared/utils';
import { MainButton } from 'twenty-ui/input';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { useAuthorizeAppMutation } from '~/generated-metadata/graphql';
import { useNavigateApp } from '~/hooks/useNavigateApp';
type App = { id: string; name: string; logo: string };
const StyledContainer = styled.div`
display: flex;
align-items: center;
@@ -56,32 +55,54 @@ const StyledButtonContainer = styled.div`
gap: 10px;
width: 100%;
`;
const StyledScopeList = styled.ul`
list-style: none;
padding: 0;
margin: 0 0 ${({ theme }) => theme.spacing(4)} 0;
width: 100%;
`;
const StyledScopeItem = styled.li`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.md};
padding: ${({ theme }) => theme.spacing(1)} 0;
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
&:last-child {
border-bottom: none;
}
`;
export const Authorize = () => {
const { t } = useLingui();
const navigate = useNavigateApp();
const [searchParam] = useSearchParams();
const { redirect } = useRedirect();
//TODO: Replace with db call for registered third party apps
const [apps] = useState<App[]>([
{
id: 'chrome',
name: 'Chrome Extension',
logo: 'images/integrations/chrome-icon.svg',
},
]);
const [app, setApp] = useState<App>();
const oauthScopeLabels: Record<string, string> = {
api: t`Access workspace data`,
profile: t`Read your profile`,
};
const clientId = searchParam.get('clientId');
const codeChallenge = searchParam.get('codeChallenge');
const redirectUrl = searchParam.get('redirectUrl');
useEffect(() => {
const app = apps.find((app) => app.id === clientId);
if (!isDefined(app)) navigate(AppPath.NotFound);
else setApp(app);
//eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { data, loading } = useQuery(FIND_APP_REGISTRATION_BY_CLIENT_ID, {
variables: { clientId: clientId ?? '' },
skip: !isDefined(clientId),
});
const appRegistration = data?.findAppRegistrationByClientId;
const [authorizeApp] = useAuthorizeAppMutation();
if (!loading && !isDefined(appRegistration) && isDefined(clientId)) {
navigate(AppPath.NotFound);
return null;
}
const handleAuthorize = async () => {
if (
isDefined(clientId) &&
@@ -94,14 +115,19 @@ export const Authorize = () => {
codeChallenge,
redirectUrl,
},
onCompleted: (data) => {
redirect(data.authorizeApp.redirectUrl);
onCompleted: (responseData) => {
redirect(responseData.authorizeApp.redirectUrl);
},
});
}
};
const appName = app?.name;
if (loading || !appRegistration) {
return null;
}
const appName = appRegistration.name;
const requestedScopes: string[] = appRegistration.scopes ?? [];
return (
<StyledContainer>
@@ -119,11 +145,19 @@ export const Authorize = () => {
height={60}
width={60}
/>
<img src={app?.logo} alt="app-icon" height={40} width={40} />
</StyledAppsContainer>
<StyledText>
<Trans>{appName} wants to access your account</Trans>
</StyledText>
{requestedScopes.length > 0 && (
<StyledScopeList>
{requestedScopes.map((scope) => (
<StyledScopeItem key={scope}>
{oauthScopeLabels[scope] ?? scope}
</StyledScopeItem>
))}
</StyledScopeList>
)}
<StyledButtonContainer>
<UndecoratedLink to={AppPath.Index}>
<MainButton title={t`Cancel`} variant="secondary" fullWidth />
@@ -0,0 +1,603 @@
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
import {
DELETE_APP_REGISTRATION,
ROTATE_APP_REGISTRATION_CLIENT_SECRET,
UPDATE_APP_REGISTRATION,
UPDATE_APP_REGISTRATION_VARIABLE,
} from '@/settings/app-registrations/graphql/mutations';
import {
FIND_APP_REGISTRATION_STATS,
FIND_APP_REGISTRATION_VARIABLES,
FIND_MANY_APP_REGISTRATIONS,
FIND_ONE_APP_REGISTRATION,
} from '@/settings/app-registrations/graphql/queries';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { ApiKeyInput } from '@/settings/developers/components/ApiKeyInput';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useMutation, useQuery } from '@apollo/client';
import styled from '@emotion/styled';
import { Trans, useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isValidUrl } from 'twenty-shared/utils';
import {
H2Title,
IconChartBar,
IconCheck,
IconDownload,
IconKey,
IconLock,
IconRefresh,
IconShield,
IconTag,
IconTextCaption,
IconTrash,
IconWorld,
Status,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { appRegistrationClientSecretFamilyState } from '~/pages/settings/applications/states/appRegistrationClientSecretFamilyState';
const DELETE_REGISTRATION_MODAL_ID = 'delete-app-registration-modal';
const ROTATE_SECRET_MODAL_ID = 'rotate-app-registration-secret-modal';
const StyledInputContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
const StyledRedirectUriRow = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(1)} 0;
`;
const StyledRedirectUriValue = styled.span`
color: ${({ theme }) => theme.font.color.primary};
font-family: monospace;
word-break: break-all;
`;
const StyledVariableRow = styled.div`
align-items: center;
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(2)} 0;
`;
const StyledVariableInfo = styled.div`
flex: 1;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(0.5)};
`;
const StyledVariableKey = styled.span`
color: ${({ theme }) => theme.font.color.primary};
font-family: monospace;
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
const StyledVariableDescription = styled.span`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
`;
const StyledRotateContainer = styled.div`
padding-top: ${({ theme }) => theme.spacing(2)};
`;
type ServerVariable = {
id: string;
key: string;
description: string;
isSecret: boolean;
isRequired: boolean;
isFilled: boolean;
};
export const SettingsAppRegistrationDetails = () => {
const { t } = useLingui();
const navigate = useNavigateSettings();
const { copyToClipboard } = useCopyToClipboard();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { openModal } = useModal();
const { appRegistrationId = '' } = useParams<{
appRegistrationId: string;
}>();
const clientSecret = useAtomFamilyStateValue(
appRegistrationClientSecretFamilyState,
appRegistrationId,
);
const [isLoading, setIsLoading] = useState(false);
const [formRedirectUris, setFormRedirectUris] = useState<string[]>([]);
const [newRedirectUri, setNewRedirectUri] = useState('');
const [hasChanges, setHasChanges] = useState(false);
const [rotatedSecret, setRotatedSecret] = useState<string | null>(null);
const [variableValues, setVariableValues] = useState<Record<string, string>>(
{},
);
const { data, loading } = useQuery(FIND_ONE_APP_REGISTRATION, {
variables: { id: appRegistrationId },
skip: !appRegistrationId,
onCompleted: (result) => {
const foundRegistration = result?.findOneAppRegistration;
if (foundRegistration) {
setFormRedirectUris(foundRegistration.redirectUris ?? []);
}
},
});
const { data: variablesData } = useQuery(FIND_APP_REGISTRATION_VARIABLES, {
variables: { appRegistrationId },
skip: !appRegistrationId,
});
const { data: statsData } = useQuery(FIND_APP_REGISTRATION_STATS, {
variables: { id: appRegistrationId },
skip: !appRegistrationId,
});
const [updateRegistration] = useMutation(UPDATE_APP_REGISTRATION, {
refetchQueries: [FIND_ONE_APP_REGISTRATION, FIND_MANY_APP_REGISTRATIONS],
});
const [deleteRegistration] = useMutation(DELETE_APP_REGISTRATION, {
refetchQueries: [FIND_MANY_APP_REGISTRATIONS],
});
const [rotateSecret] = useMutation(ROTATE_APP_REGISTRATION_CLIENT_SECRET);
const [updateVariable] = useMutation(UPDATE_APP_REGISTRATION_VARIABLE, {
refetchQueries: [FIND_APP_REGISTRATION_VARIABLES],
});
const registration = data?.findOneAppRegistration;
const variables: ServerVariable[] =
variablesData?.findAppRegistrationVariables ?? [];
if (loading || !registration) {
return null;
}
const markDirty = () => setHasChanges(true);
const handleSave = async () => {
setIsLoading(true);
try {
await updateRegistration({
variables: {
input: {
id: appRegistrationId,
redirectUris: formRedirectUris,
},
},
});
setHasChanges(false);
enqueueSuccessSnackBar({ message: t`App updated` });
} catch {
enqueueErrorSnackBar({ message: t`Error updating app` });
} finally {
setIsLoading(false);
}
};
const handleCancel = () => {
setFormRedirectUris(registration.redirectUris ?? []);
setHasChanges(false);
};
const handleDelete = async () => {
setIsLoading(true);
try {
await deleteRegistration({
variables: { id: appRegistrationId },
});
navigate(SettingsPath.Applications);
} catch {
enqueueErrorSnackBar({
message: t`Error deleting app`,
});
} finally {
setIsLoading(false);
}
};
const handleRotateSecret = async () => {
try {
const result = await rotateSecret({
variables: { id: appRegistrationId },
});
const secret =
result.data?.rotateAppRegistrationClientSecret?.clientSecret;
if (secret) {
setRotatedSecret(secret);
enqueueSuccessSnackBar({
message: t`Client secret rotated. Copy it now — it won't be shown again.`,
});
}
} catch {
enqueueErrorSnackBar({
message: t`Error rotating client secret`,
});
}
};
const handleAddRedirectUri = () => {
const trimmed = newRedirectUri.trim();
if (!trimmed) {
return;
}
if (!isValidUrl(trimmed)) {
enqueueErrorSnackBar({ message: t`Please enter a valid URL` });
return;
}
setFormRedirectUris([...formRedirectUris, trimmed]);
setNewRedirectUri('');
markDirty();
};
const handleRemoveRedirectUri = (index: number) => {
setFormRedirectUris(
formRedirectUris.filter((_, uriIndex) => uriIndex !== index),
);
markDirty();
};
const handleSaveVariableValue = async (variable: ServerVariable) => {
const value = variableValues[variable.id];
if (!isNonEmptyString(value)) {
return;
}
try {
await updateVariable({
variables: {
input: {
id: variable.id,
value,
},
},
});
setVariableValues((previous) => {
const next = { ...previous };
delete next[variable.id];
return next;
});
enqueueSuccessSnackBar({
message: t`Variable ${variable.key} updated`,
});
} catch {
enqueueErrorSnackBar({
message: t`Error updating variable`,
});
}
};
const displayedSecret = clientSecret ?? rotatedSecret;
const confirmationValue = t`yes`;
const credentialItems = [
{
Icon: IconKey,
label: t`Client ID`,
value: registration.clientId,
onClick: () =>
copyToClipboard(registration.clientId, t`Client ID copied`),
},
{
Icon: IconLock,
label: t`Client Secret`,
value: displayedSecret ? t`Visible below` : '••••••••••••••••',
},
{
Icon: IconShield,
label: t`Scopes`,
value: (registration.scopes ?? []).join(', ') || '—',
},
];
const generalItems = [
{
Icon: IconTag,
label: t`Name`,
value: registration.name,
},
...(isNonEmptyString(registration.description)
? [
{
Icon: IconTextCaption,
label: t`Description`,
value: registration.description,
},
]
: []),
{
Icon: IconWorld,
label: t`Universal ID`,
value: registration.universalIdentifier,
onClick: () =>
copyToClipboard(
registration.universalIdentifier,
t`Universal identifier copied`,
),
},
];
const stats = statsData?.findAppRegistrationStats;
const hasActiveInstalls = (stats?.activeInstalls ?? 0) > 0;
const versionDistributionLabel =
stats?.versionDistribution
?.map(
(entry: { version: string; count: number }) =>
`${entry.version} (${entry.count})`,
)
.join(', ') || '—';
const statsItems = [
{
Icon: IconDownload,
label: t`Active installs`,
value: stats?.activeInstalls ?? '—',
},
{
Icon: IconTag,
label: t`Latest version`,
value: stats?.latestVersion ?? '—',
},
{
Icon: IconChartBar,
label: t`Distribution`,
value: versionDistributionLabel,
},
];
return (
<>
<SubMenuTopBarContainer
title={registration.name}
links={[
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`Applications`,
href: getSettingsPath(SettingsPath.Applications),
},
{ children: registration.name },
]}
actionButton={
hasChanges ? (
<SaveAndCancelButtons
isSaveDisabled={isLoading}
onCancel={handleCancel}
onSave={handleSave}
/>
) : undefined
}
>
<SettingsPageContainer>
{stats && stats.activeInstalls > 0 && (
<Section>
<H2Title
title={t`Install Stats`}
description={t`Usage across all workspaces on this server`}
/>
<SettingsAdminTableCard
rounded
items={statsItems}
gridAutoColumns="3fr 8fr"
/>
</Section>
)}
<Section>
<H2Title
title={t`General`}
description={t`Name and description are managed via your app manifest (CLI)`}
/>
<SettingsAdminTableCard
rounded
items={generalItems}
gridAutoColumns="3fr 8fr"
/>
</Section>
<Section>
<H2Title
title={t`OAuth Credentials`}
description={t`Credentials and scopes for OAuth authorization flows`}
/>
<SettingsAdminTableCard
rounded
items={credentialItems}
gridAutoColumns="3fr 8fr"
/>
{displayedSecret && <ApiKeyInput apiKey={displayedSecret} />}
<StyledRotateContainer>
<Button
Icon={IconRefresh}
title={t`Rotate client secret`}
variant="secondary"
size="medium"
onClick={() => openModal(ROTATE_SECRET_MODAL_ID)}
/>
</StyledRotateContainer>
</Section>
<Section>
<H2Title
title={t`Redirect URIs`}
description={t`Allowed redirect URIs for OAuth flows`}
/>
{formRedirectUris.map((uri, index) => (
<StyledRedirectUriRow key={`${uri}-${index}`}>
<StyledRedirectUriValue>{uri}</StyledRedirectUriValue>
<Button
Icon={IconTrash}
variant="tertiary"
size="medium"
onClick={() => handleRemoveRedirectUri(index)}
/>
</StyledRedirectUriRow>
))}
<StyledInputContainer>
<SettingsTextInput
instanceId="app-registration-new-redirect-uri"
value={newRedirectUri}
onChange={setNewRedirectUri}
placeholder={t`https://example.com/callback`}
fullWidth
/>
<Button
title={t`Add`}
variant="secondary"
size="medium"
onClick={handleAddRedirectUri}
/>
</StyledInputContainer>
</Section>
{variables.length > 0 && (
<Section>
<H2Title
title={t`Server Variables`}
description={t`Variables declared by the app manifest. Fill in values here — they apply to all workspace installations.`}
/>
{variables.map((variable) => (
<StyledVariableRow key={variable.id}>
<StyledVariableInfo>
<StyledVariableKey>
{variable.key}
{variable.isRequired && (
<span style={{ color: 'red' }}> *</span>
)}
</StyledVariableKey>
{isNonEmptyString(variable.description) && (
<StyledVariableDescription>
{variable.description}
</StyledVariableDescription>
)}
</StyledVariableInfo>
{variable.isFilled &&
!isNonEmptyString(variableValues[variable.id]) && (
<Status color="green" text={t`Configured`} />
)}
{!variable.isFilled &&
!isNonEmptyString(variableValues[variable.id]) && (
<Status
color={variable.isRequired ? 'red' : 'gray'}
text={variable.isRequired ? t`Required` : t`Not set`}
/>
)}
<SettingsTextInput
instanceId={`var-${variable.id}`}
value={variableValues[variable.id] ?? ''}
onChange={(value) =>
setVariableValues((previous) => ({
...previous,
[variable.id]: value,
}))
}
placeholder={
variable.isSecret ? t`Enter secret value` : t`Enter value`
}
fullWidth
/>
<Button
Icon={IconCheck}
variant="secondary"
size="small"
disabled={!isNonEmptyString(variableValues[variable.id])}
onClick={() => handleSaveVariableValue(variable)}
/>
</StyledVariableRow>
))}
</Section>
)}
<Section>
<H2Title
title={t`Danger zone`}
description={
hasActiveInstalls
? t`Uninstall this app from all workspaces before deleting it`
: t`Delete this app`
}
/>
<Button
accent="danger"
variant="secondary"
title={t`Delete`}
Icon={IconTrash}
disabled={hasActiveInstalls}
onClick={() => openModal(DELETE_REGISTRATION_MODAL_ID)}
/>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
<ConfirmationModal
confirmationPlaceholder={confirmationValue}
confirmationValue={confirmationValue}
modalId={ROTATE_SECRET_MODAL_ID}
title={t`Rotate client secret`}
subtitle={
<Trans>
If you rotate this secret, any integration using the current secret
will stop working. Please type {`"${confirmationValue}"`} to
confirm.
</Trans>
}
onConfirmClick={handleRotateSecret}
confirmButtonText={t`Rotate secret`}
loading={isLoading}
/>
<ConfirmationModal
confirmationPlaceholder={confirmationValue}
confirmationValue={confirmationValue}
modalId={DELETE_REGISTRATION_MODAL_ID}
title={t`Delete app`}
subtitle={
<Trans>
Please type {`"${confirmationValue}"`} to confirm you want to delete
this app. All workspace installations linked to it will lose their
OAuth credentials.
</Trans>
}
onConfirmClick={handleDelete}
confirmButtonText={t`Delete`}
loading={isLoading}
/>
</>
);
};
@@ -1,3 +1,4 @@
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
@@ -10,11 +11,12 @@ import { getSettingsPath } from 'twenty-shared/utils';
import { IconApps, IconCode, IconDownload } from 'twenty-ui/display';
import {
type FeatureFlagKey,
PermissionFlagType,
useFindManyApplicationsQuery,
} from '~/generated-metadata/graphql';
import { SettingsApplicationsTable } from '~/pages/settings/applications/components/SettingsApplicationsTable';
import { SettingsApplicationsAvailableTab } from '~/pages/settings/applications/tabs/SettingsApplicationsAvailableTab';
import { SettingsApplicationsCreateTab } from '~/pages/settings/applications/tabs/SettingsApplicationsCreateTab';
import { SettingsApplicationsDeveloperTab } from '~/pages/settings/applications/tabs/SettingsApplicationsDeveloperTab';
import { SettingsApplicationsInstalledTab } from '~/pages/settings/applications/tabs/SettingsApplicationsInstalledTab';
const APPLICATIONS_TAB_LIST_ID = 'applications-tab-list';
@@ -22,6 +24,10 @@ const APPLICATIONS_TAB_LIST_ID = 'applications-tab-list';
export const SettingsApplications = () => {
const { t } = useLingui();
const hasDeveloperAccess = useHasPermissionFlag(
PermissionFlagType.API_KEYS_AND_WEBHOOKS,
);
const isMarketplaceEnabled = useIsFeatureEnabled(
'IS_MARKETPLACE_ENABLED' as FeatureFlagKey,
);
@@ -51,26 +57,28 @@ export const SettingsApplications = () => {
{applications.length > 0 && (
<SettingsApplicationsTable applications={applications} />
)}
<SettingsApplicationsCreateTab />
{hasDeveloperAccess && <SettingsApplicationsDeveloperTab />}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
}
const tabs = [
{ id: 'available', title: t`Available`, Icon: IconDownload },
{ id: 'marketplace', title: t`Marketplace`, Icon: IconDownload },
{ id: 'installed', title: t`Installed`, Icon: IconApps },
{ id: 'create', title: t`Create an app`, Icon: IconCode },
...(hasDeveloperAccess
? [{ id: 'developer', title: t`Developer`, Icon: IconCode }]
: []),
];
const renderActiveTabContent = () => {
switch (activeTabId) {
case 'available':
case 'marketplace':
return <SettingsApplicationsAvailableTab />;
case 'installed':
return <SettingsApplicationsInstalledTab />;
case 'create':
return <SettingsApplicationsCreateTab />;
case 'developer':
return <SettingsApplicationsDeveloperTab />;
default:
return <SettingsApplicationsAvailableTab />;
}
@@ -0,0 +1,7 @@
import { createAtomFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomFamilyState';
export const appRegistrationClientSecretFamilyState =
createAtomFamilyState<string | null, string>({
key: 'appRegistrationClientSecretState',
defaultValue: null,
});
@@ -1,68 +0,0 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import {
CommandBlock,
H2Title,
IconCopy,
IconFileInfo,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const StyledButtonContainer = styled.div`
margin: ${({ theme }) => theme.spacing(2)} 0;
`;
export const SettingsApplicationsCreateTab = () => {
const { t } = useLingui();
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const { copyToClipboard } = useCopyToClipboard();
const commands = [
// eslint-disable-next-line lingui/no-unlocalized-strings
'npx create-twenty-app@latest my-twenty-app',
// eslint-disable-next-line lingui/no-unlocalized-strings
'cd my-twenty-app',
];
const button = (
<Button
onClick={() => {
copyToClipboard(commands.join('\n'), t`Commands copied to clipboard`);
}}
ariaLabel={t`Copy commands`}
Icon={IconCopy}
/>
);
return (
<Section>
<H2Title
title={t`Create an application`}
description={t`You can either create a private app or share it to others`}
/>
<CommandBlock commands={commands} button={button} />
<StyledButtonContainer>
<Button
Icon={IconFileInfo}
title={t`Read documentation`}
onClick={() =>
window.open(
getDocumentationUrl({
locale: currentWorkspaceMember?.locale,
path: '/developers/extend/capabilities/apps',
}),
'_blank',
)
}
/>
</StyledButtonContainer>
</Section>
);
};
@@ -0,0 +1,118 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { FIND_MANY_APP_REGISTRATIONS } from '@/settings/app-registrations/graphql/queries';
import { SettingsListCard } from '@/settings/components/SettingsListCard';
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import styled from '@emotion/styled';
import { useTheme } from '@emotion/react';
import { useLingui } from '@lingui/react/macro';
import { useQuery } from '@apollo/client';
import { useNavigate } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import {
CommandBlock,
H2Title,
IconApps,
IconChevronRight,
IconCopy,
IconFileInfo,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const StyledButtonContainer = styled.div`
margin: ${({ theme }) => theme.spacing(2)} 0;
`;
type AppRegistration = {
id: string;
name: string;
description: string | null;
};
export const SettingsApplicationsDeveloperTab = () => {
const { t } = useLingui();
const navigate = useNavigate();
const theme = useTheme();
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const { copyToClipboard } = useCopyToClipboard();
const { data, loading } = useQuery(FIND_MANY_APP_REGISTRATIONS);
const registrations: AppRegistration[] =
data?.findManyAppRegistrations ?? [];
const commands = [
// eslint-disable-next-line lingui/no-unlocalized-strings
'npx create-twenty-app@latest my-twenty-app',
// eslint-disable-next-line lingui/no-unlocalized-strings
'cd my-twenty-app',
];
const copyButton = (
<Button
onClick={() => {
copyToClipboard(commands.join('\n'), t`Commands copied to clipboard`);
}}
ariaLabel={t`Copy commands`}
Icon={IconCopy}
/>
);
return (
<>
<Section>
<H2Title
title={t`Create an application`}
description={t`You can either create a private app or share it to others`}
/>
<CommandBlock commands={commands} button={copyButton} />
<StyledButtonContainer>
<Button
Icon={IconFileInfo}
title={t`Read documentation`}
onClick={() =>
window.open(
getDocumentationUrl({
locale: currentWorkspaceMember?.locale,
path: '/developers/extend/capabilities/apps',
}),
'_blank',
)
}
/>
</StyledButtonContainer>
</Section>
{registrations.length > 0 && (
<Section>
<H2Title
title={t`My Apps`}
description={t`Apps you've created and published`}
/>
<SettingsListCard
items={registrations}
getItemLabel={(registration) => registration.name}
isLoading={loading}
RowIcon={IconApps}
onRowClick={(registration) => {
navigate(
getSettingsPath(SettingsPath.AppRegistrationDetail, {
appRegistrationId: registration.id,
}),
);
}}
RowRightComponent={() => (
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
)}
/>
</Section>
)}
</>
);
};
@@ -28,6 +28,18 @@ export const EXPECTED_MANIFEST: Manifest = {
value: 'Alex Karp',
},
},
serverVariables: {
POSTCARD_API_KEY: {
description: 'API key for the postcard printing service',
isSecret: true,
isRequired: true,
},
POSTCARD_SENDER_NAME: {
description: 'Default sender name on postcards',
isSecret: false,
isRequired: false,
},
},
description: 'A simple rich app',
displayName: 'Rich App',
defaultRoleUniversalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
@@ -14,5 +14,17 @@ export default defineApplication({
isSecret: false,
},
},
serverVariables: {
POSTCARD_API_KEY: {
description: 'API key for the postcard printing service',
isSecret: true,
isRequired: true,
},
POSTCARD_SENDER_NAME: {
description: 'Default sender name on postcards',
isSecret: false,
isRequired: false,
},
},
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
});
@@ -260,8 +260,125 @@ export class ApiService {
}
}
async findAppRegistrationByUniversalIdentifier(
universalIdentifier: string,
): Promise<
ApiResponse<{
id: string;
universalIdentifier: string;
name: string;
clientId: string;
} | null>
> {
try {
const query = `
query FindAppRegistrationByUniversalIdentifier($universalIdentifier: String!) {
findAppRegistrationByUniversalIdentifier(universalIdentifier: $universalIdentifier) {
id
universalIdentifier
name
clientId
}
}
`;
const response = await this.client.post(
'/metadata',
{
query,
variables: { universalIdentifier },
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0],
};
}
return {
success: true,
data: response.data.data.findAppRegistrationByUniversalIdentifier,
};
} catch (error) {
return {
success: false,
error,
};
}
}
async createAppRegistration(input: {
name: string;
description?: string;
universalIdentifier: string;
}): Promise<
ApiResponse<{
appRegistration: {
id: string;
universalIdentifier: string;
clientId: string;
};
clientSecret: string;
}>
> {
try {
const mutation = `
mutation CreateAppRegistration($input: CreateAppRegistrationInput!) {
createAppRegistration(input: $input) {
appRegistration {
id
universalIdentifier
clientId
}
clientSecret
}
}
`;
const response = await this.client.post(
'/metadata',
{
query: mutation,
variables: { input },
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0],
};
}
return {
success: true,
data: response.data.data.createAppRegistration,
};
} catch (error) {
return {
success: false,
error,
};
}
}
async createApplication(
manifest: Manifest,
options?: { appRegistrationId?: string },
): Promise<ApiResponse<{ id: string; universalIdentifier: string }>> {
try {
const mutation = `
@@ -273,13 +390,19 @@ export class ApiService {
}
`;
const input: Record<string, string> = {
universalIdentifier: manifest.application.universalIdentifier,
name: manifest.application.displayName,
version: '0.0.1',
sourcePath: 'cli-sync',
};
if (options?.appRegistrationId) {
input.appRegistrationId = options.appRegistrationId;
}
const variables = {
input: {
universalIdentifier: manifest.application.universalIdentifier,
name: manifest.application.displayName,
version: '0.0.1',
sourcePath: 'cli-sync',
},
input,
};
const response: AxiosResponse = await this.client.post(
@@ -8,6 +8,8 @@ export type TwentyConfig = {
apiKey?: string;
applicationAccessToken?: string;
applicationRefreshToken?: string;
oauthClientId?: string;
oauthClientSecret?: string;
};
type PersistedConfig = TwentyConfig & {
@@ -6,6 +6,7 @@ import { BuildManifestOrchestratorStep } from '@/cli/utilities/dev/orchestrator/
import { CheckServerOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/check-server-orchestrator-step';
import { EnsureValidTokensOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/ensure-valid-tokens-orchestrator-step';
import { GenerateApiClientOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/generate-api-client-orchestrator-step';
import { RegisterAppOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/register-app-orchestrator-step';
import { ResolveApplicationOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/resolve-application-orchestrator-step';
import {
StartWatchersOrchestratorStep,
@@ -34,6 +35,7 @@ export class DevModeOrchestrator {
private checkServerStep: CheckServerOrchestratorStep;
private ensureValidTokensStep: EnsureValidTokensOrchestratorStep;
private buildManifestStep: BuildManifestOrchestratorStep;
private registerAppStep: RegisterAppOrchestratorStep;
private resolveApplicationStep: ResolveApplicationOrchestratorStep;
private uploadFilesStep: UploadFilesOrchestratorStep;
private generateApiClientStep: GenerateApiClientOrchestratorStep;
@@ -59,6 +61,11 @@ export class DevModeOrchestrator {
configService,
});
this.buildManifestStep = new BuildManifestOrchestratorStep(stepDeps);
this.registerAppStep = new RegisterAppOrchestratorStep({
...stepDeps,
apiService,
configService,
});
this.resolveApplicationStep = new ResolveApplicationOrchestratorStep({
...stepDeps,
apiService,
@@ -216,8 +223,11 @@ export class DevModeOrchestrator {
}
private async initializePipeline(manifest: Manifest): Promise<boolean> {
const registerResult = await this.registerAppStep.execute({ manifest });
const resolveResult = await this.resolveApplicationStep.execute({
manifest,
appRegistrationId: registerResult.appRegistrationId ?? undefined,
});
if (!resolveResult.applicationId) {
@@ -0,0 +1,120 @@
import { type ApiService } from '@/cli/utilities/api/api-service';
import { type ConfigService } from '@/cli/utilities/config/config-service';
import { type OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
import { type Manifest } from 'twenty-shared/application';
export type RegisterAppOrchestratorStepOutput = {
appRegistrationId: string | null;
clientId: string | null;
};
export class RegisterAppOrchestratorStep {
private apiService: ApiService;
private configService: ConfigService;
private state: OrchestratorState;
private notify: () => void;
constructor({
apiService,
configService,
state,
notify,
}: {
apiService: ApiService;
configService: ConfigService;
state: OrchestratorState;
notify: () => void;
}) {
this.apiService = apiService;
this.configService = configService;
this.state = state;
this.notify = notify;
}
async execute(input: {
manifest: Manifest;
}): Promise<RegisterAppOrchestratorStepOutput> {
const universalIdentifier = input.manifest.application.universalIdentifier;
const findResult =
await this.apiService.findAppRegistrationByUniversalIdentifier(
universalIdentifier,
);
if (!findResult.success) {
this.state.applyStepEvents([
{
message: 'Failed to check app registration',
status: 'warning',
},
]);
this.notify();
return { appRegistrationId: null, clientId: null };
}
if (findResult.data) {
this.state.applyStepEvents([
{
message: `App registration found: ${findResult.data.name}`,
status: 'info',
},
]);
this.notify();
return {
appRegistrationId: findResult.data.id,
clientId: findResult.data.clientId,
};
}
const createResult = await this.apiService.createAppRegistration({
name: input.manifest.application.displayName,
description: input.manifest.application.description,
universalIdentifier,
});
if (!createResult.success || !createResult.data) {
this.state.applyStepEvents([
{
message: 'Failed to create app registration',
status: 'warning',
},
]);
this.notify();
return { appRegistrationId: null, clientId: null };
}
await this.configService.setConfig({
oauthClientId: createResult.data.appRegistration.clientId,
oauthClientSecret: createResult.data.clientSecret,
});
this.state.applyStepEvents([
{
message: `App registration created: ${input.manifest.application.displayName}`,
status: 'success',
},
{
message: `Client ID: ${createResult.data.appRegistration.clientId}`,
status: 'info',
},
{
message: `Client Secret: ${createResult.data.clientSecret}`,
status: 'warning',
},
{
message:
'Credentials saved to config. The secret will not be shown again.',
status: 'warning',
},
]);
this.notify();
return {
appRegistrationId: createResult.data.appRegistration.id,
clientId: createResult.data.appRegistration.clientId,
};
}
}
@@ -28,6 +28,7 @@ export class ResolveApplicationOrchestratorStep {
async execute(input: {
manifest: Manifest;
appRegistrationId?: string;
}): Promise<ResolveApplicationOrchestratorStepOutput> {
const step = this.state.steps.resolveApplication;
@@ -65,6 +66,7 @@ export class ResolveApplicationOrchestratorStep {
const createResult = await this.apiService.createApplication(
input.manifest,
{ appRegistrationId: input.appRegistrationId },
);
if (!createResult.success) {
@@ -0,0 +1,111 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateAppRegistration1772000000000 implements MigrationInterface {
name = 'CreateAppRegistration1772000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "core"."appRegistration" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"universalIdentifier" uuid NOT NULL,
"name" text NOT NULL,
"description" text,
"logoUrl" text,
"author" text,
"clientId" text NOT NULL,
"clientSecretHash" text,
"redirectUris" text[] NOT NULL DEFAULT '{}',
"scopes" text[] NOT NULL DEFAULT '{}',
"createdByUserId" uuid,
"websiteUrl" text,
"termsUrl" text,
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"deletedAt" TIMESTAMP WITH TIME ZONE,
CONSTRAINT "PK_app_registration" PRIMARY KEY ("id"),
CONSTRAINT "FK_app_registration_created_by_user" FOREIGN KEY ("createdByUserId") REFERENCES "core"."user"("id") ON DELETE SET NULL
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX "IDX_APP_REGISTRATION_UNIVERSAL_IDENTIFIER_UNIQUE"
ON "core"."appRegistration" ("universalIdentifier")
WHERE "deletedAt" IS NULL
`);
await queryRunner.query(`
CREATE UNIQUE INDEX "IDX_APP_REGISTRATION_CLIENT_ID_UNIQUE"
ON "core"."appRegistration" ("clientId")
WHERE "deletedAt" IS NULL
`);
await queryRunner.query(`
CREATE INDEX "IDX_APP_REGISTRATION_CREATED_BY_USER_ID"
ON "core"."appRegistration" ("createdByUserId")
`);
await queryRunner.query(`
CREATE TABLE "core"."appRegistrationVariable" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"key" text NOT NULL,
"encryptedValue" text NOT NULL DEFAULT '',
"description" text NOT NULL DEFAULT '',
"isSecret" boolean NOT NULL DEFAULT true,
"isRequired" boolean NOT NULL DEFAULT false,
"appRegistrationId" uuid NOT NULL,
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_app_registration_variable" PRIMARY KEY ("id"),
CONSTRAINT "FK_app_registration_variable_app_registration" FOREIGN KEY ("appRegistrationId") REFERENCES "core"."appRegistration"("id") ON DELETE CASCADE,
CONSTRAINT "IDX_APP_REGISTRATION_VARIABLE_KEY_APP_REGISTRATION_ID_UNIQUE" UNIQUE ("key", "appRegistrationId")
)
`);
await queryRunner.query(`
CREATE INDEX "IDX_APP_REGISTRATION_VARIABLE_APP_REGISTRATION_ID"
ON "core"."appRegistrationVariable" ("appRegistrationId")
`);
await queryRunner.query(
`ALTER TABLE "core"."application" ADD "appRegistrationId" uuid`,
);
await queryRunner.query(`
ALTER TABLE "core"."application"
ADD CONSTRAINT "FK_application_app_registration"
FOREIGN KEY ("appRegistrationId") REFERENCES "core"."appRegistration"("id") ON DELETE SET NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."application" DROP CONSTRAINT "FK_application_app_registration"`,
);
await queryRunner.query(
`ALTER TABLE "core"."application" DROP COLUMN "appRegistrationId"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_APP_REGISTRATION_VARIABLE_APP_REGISTRATION_ID"`,
);
await queryRunner.query(
`DROP TABLE "core"."appRegistrationVariable"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_APP_REGISTRATION_CREATED_BY_USER_ID"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_APP_REGISTRATION_CLIENT_ID_UNIQUE"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_APP_REGISTRATION_UNIVERSAL_IDENTIFIER_UNIQUE"`,
);
await queryRunner.query(`DROP TABLE "core"."appRegistration"`);
}
}
@@ -0,0 +1,58 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { AppRegistrationEncryptionService } from 'src/engine/core-modules/app-registration/app-registration-encryption.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
describe('AppRegistrationEncryptionService', () => {
let service: AppRegistrationEncryptionService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AppRegistrationEncryptionService,
{
provide: TwentyConfigService,
useValue: {
get: jest.fn().mockReturnValue('test-app-secret-32-chars-long!!!'),
},
},
],
}).compile();
service = module.get(AppRegistrationEncryptionService);
});
it('should encrypt and decrypt a value', () => {
const plaintext = 'my-secret-api-key';
const encrypted = service.encrypt(plaintext);
expect(encrypted).not.toBe(plaintext);
expect(encrypted.length).toBeGreaterThan(0);
const decrypted = service.decrypt(encrypted);
expect(decrypted).toBe(plaintext);
});
it('should produce different ciphertexts for the same plaintext', () => {
const plaintext = 'same-value';
const encrypted1 = service.encrypt(plaintext);
const encrypted2 = service.encrypt(plaintext);
expect(encrypted1).not.toBe(encrypted2);
expect(service.decrypt(encrypted1)).toBe(plaintext);
expect(service.decrypt(encrypted2)).toBe(plaintext);
});
it('should handle empty strings', () => {
const encrypted = service.encrypt('');
expect(service.decrypt(encrypted)).toBe('');
});
it('should return empty string when decrypting empty input', () => {
expect(service.decrypt('')).toBe('');
});
});
@@ -0,0 +1,116 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { type Repository } from 'typeorm';
import { AppRegistrationEntity } from 'src/engine/core-modules/app-registration/app-registration.entity';
import { AppRegistrationIdentifierGuardService } from 'src/engine/core-modules/app-registration/app-registration-identifier-guard.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
describe('AppRegistrationIdentifierGuardService', () => {
let service: AppRegistrationIdentifierGuardService;
let appRegistrationRepository: jest.Mocked<
Repository<AppRegistrationEntity>
>;
let applicationRepository: jest.Mocked<Repository<ApplicationEntity>>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AppRegistrationIdentifierGuardService,
{
provide: getRepositoryToken(AppRegistrationEntity),
useValue: {
findOne: jest.fn(),
},
},
{
provide: getRepositoryToken(ApplicationEntity),
useValue: {
findOne: jest.fn(),
},
},
],
}).compile();
service = module.get(AppRegistrationIdentifierGuardService);
appRegistrationRepository = module.get(
getRepositoryToken(AppRegistrationEntity),
);
applicationRepository = module.get(
getRepositoryToken(ApplicationEntity),
);
});
it('should allow when application has no appRegistrationId (grandfather policy)', async () => {
applicationRepository.findOne.mockResolvedValue({
id: 'app-1',
appRegistrationId: null,
} as ApplicationEntity);
const result = await service.validateUniversalIdentifierOwnership({
universalIdentifier: 'some-uid',
applicationId: 'app-1',
});
expect(result.isValid).toBe(true);
});
it('should allow when identifier is not claimed by any registration', async () => {
applicationRepository.findOne.mockResolvedValue({
id: 'app-1',
appRegistrationId: 'reg-1',
} as ApplicationEntity);
appRegistrationRepository.findOne.mockResolvedValue(null);
const result = await service.validateUniversalIdentifierOwnership({
universalIdentifier: 'unclaimed-uid',
applicationId: 'app-1',
});
expect(result.isValid).toBe(true);
});
it('should allow when identifier is claimed by the same registration', async () => {
applicationRepository.findOne.mockResolvedValue({
id: 'app-1',
appRegistrationId: 'reg-1',
} as ApplicationEntity);
appRegistrationRepository.findOne.mockResolvedValue({
id: 'reg-1',
universalIdentifier: 'my-uid',
name: 'My App',
} as AppRegistrationEntity);
const result = await service.validateUniversalIdentifierOwnership({
universalIdentifier: 'my-uid',
applicationId: 'app-1',
});
expect(result.isValid).toBe(true);
});
it('should reject when identifier is claimed by a different registration', async () => {
applicationRepository.findOne.mockResolvedValue({
id: 'app-1',
appRegistrationId: 'reg-1',
name: 'My App',
} as ApplicationEntity);
appRegistrationRepository.findOne.mockResolvedValue({
id: 'reg-2',
universalIdentifier: 'stolen-uid',
name: 'Other App',
} as AppRegistrationEntity);
const result = await service.validateUniversalIdentifierOwnership({
universalIdentifier: 'stolen-uid',
applicationId: 'app-1',
});
expect(result.isValid).toBe(false);
expect(result.errorMessage).toContain('claimed by');
});
});
@@ -0,0 +1,324 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { type Repository } from 'typeorm';
import { AppRegistrationVariableEntity } from 'src/engine/core-modules/app-registration/app-registration-variable.entity';
import { AppRegistrationEncryptionService } from 'src/engine/core-modules/app-registration/app-registration-encryption.service';
import { AppRegistrationEntity } from 'src/engine/core-modules/app-registration/app-registration.entity';
import { AppRegistrationExceptionCode } from 'src/engine/core-modules/app-registration/app-registration.exception';
import { AppRegistrationService } from 'src/engine/core-modules/app-registration/app-registration.service';
describe('AppRegistrationService', () => {
let service: AppRegistrationService;
let appRegistrationRepository: jest.Mocked<
Repository<AppRegistrationEntity>
>;
let variableRepository: jest.Mocked<
Repository<AppRegistrationVariableEntity>
>;
let encryptionService: jest.Mocked<AppRegistrationEncryptionService>;
const mockUserId = 'user-123';
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AppRegistrationService,
{
provide: getRepositoryToken(AppRegistrationEntity),
useValue: {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
softDelete: jest.fn(),
},
},
{
provide: getRepositoryToken(AppRegistrationVariableEntity),
useValue: {
find: jest.fn(),
findOne: jest.fn(),
findOneOrFail: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
},
{
provide: AppRegistrationEncryptionService,
useValue: {
encrypt: jest.fn((value: string) => `enc_${value}`),
decrypt: jest.fn((value: string) =>
value.replace('enc_', ''),
),
},
},
],
}).compile();
service = module.get(AppRegistrationService);
appRegistrationRepository = module.get(
getRepositoryToken(AppRegistrationEntity),
);
variableRepository = module.get(
getRepositoryToken(AppRegistrationVariableEntity),
);
encryptionService = module.get(AppRegistrationEncryptionService);
});
describe('create', () => {
it('should always generate a client secret', async () => {
appRegistrationRepository.findOne.mockResolvedValue(null);
appRegistrationRepository.create.mockImplementation(
(entity) =>
({
...entity,
id: 'reg-1',
}) as AppRegistrationEntity,
);
appRegistrationRepository.save.mockImplementation(
async (entity) => entity as AppRegistrationEntity,
);
const result = await service.create(
{ name: 'Test App' },
mockUserId,
);
expect(result.appRegistration.name).toBe('Test App');
expect(result.clientSecret).toBeDefined();
expect(result.clientSecret.length).toBeGreaterThan(0);
expect(appRegistrationRepository.save).toHaveBeenCalled();
});
it('should accept null createdByUserId for CLI-created registrations', async () => {
appRegistrationRepository.findOne.mockResolvedValue(null);
appRegistrationRepository.create.mockImplementation(
(entity) =>
({
...entity,
id: 'reg-1',
}) as AppRegistrationEntity,
);
appRegistrationRepository.save.mockImplementation(
async (entity) => entity as AppRegistrationEntity,
);
const result = await service.create({ name: 'CLI App' }, null);
expect(result.appRegistration.createdByUserId).toBeNull();
});
it('should reject duplicate universal identifiers', async () => {
appRegistrationRepository.findOne.mockResolvedValue({
id: 'existing',
} as AppRegistrationEntity);
await expect(
service.create(
{
name: 'Dupe App',
universalIdentifier: 'existing-uid',
},
mockUserId,
),
).rejects.toMatchObject({
code: AppRegistrationExceptionCode.UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED,
});
});
it('should reject invalid scopes', async () => {
appRegistrationRepository.findOne.mockResolvedValue(null);
await expect(
service.create(
{
name: 'Bad Scopes App',
scopes: ['api', 'invalid:scope'],
},
mockUserId,
),
).rejects.toMatchObject({
code: AppRegistrationExceptionCode.INVALID_SCOPE,
});
});
});
describe('delete', () => {
it('should soft delete a registration', async () => {
appRegistrationRepository.findOne.mockResolvedValue({
id: 'reg-1',
} as AppRegistrationEntity);
const result = await service.delete('reg-1');
expect(result).toBe(true);
expect(appRegistrationRepository.softDelete).toHaveBeenCalledWith(
'reg-1',
);
});
});
describe('rotateClientSecret', () => {
it('should generate a new secret', async () => {
appRegistrationRepository.findOne.mockResolvedValue({
id: 'reg-1',
} as AppRegistrationEntity);
const secret = await service.rotateClientSecret('reg-1');
expect(secret).toBeDefined();
expect(secret.length).toBeGreaterThan(0);
expect(appRegistrationRepository.update).toHaveBeenCalledWith(
'reg-1',
expect.objectContaining({ clientSecretHash: expect.any(String) }),
);
});
});
describe('createVariable', () => {
it('should encrypt variable value', async () => {
appRegistrationRepository.findOne.mockResolvedValue({
id: 'reg-1',
} as AppRegistrationEntity);
variableRepository.create.mockImplementation(
(entity) => entity as AppRegistrationVariableEntity,
);
variableRepository.save.mockImplementation(
async (entity) => entity as AppRegistrationVariableEntity,
);
await service.createVariable({
appRegistrationId: 'reg-1',
key: 'API_KEY',
value: 'secret-value',
});
expect(encryptionService.encrypt).toHaveBeenCalledWith(
'secret-value',
);
expect(variableRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
encryptedValue: 'enc_secret-value',
}),
);
});
});
describe('syncVariableSchemas', () => {
it('should create variables for new keys', async () => {
variableRepository.findOne.mockResolvedValue(null);
variableRepository.create.mockImplementation(
(entity) => entity as AppRegistrationVariableEntity,
);
variableRepository.save.mockImplementation(
async (entity) => entity as AppRegistrationVariableEntity,
);
await service.syncVariableSchemas('reg-1', {
NOTION_CLIENT_ID: {
description: 'Notion OAuth Client ID',
isSecret: false,
isRequired: true,
},
NOTION_CLIENT_SECRET: {
description: 'Notion OAuth Client Secret',
isSecret: true,
isRequired: true,
},
});
expect(variableRepository.save).toHaveBeenCalledTimes(2);
expect(variableRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
key: 'NOTION_CLIENT_ID',
encryptedValue: '',
isSecret: false,
isRequired: true,
}),
);
expect(variableRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
key: 'NOTION_CLIENT_SECRET',
isSecret: true,
isRequired: true,
}),
);
});
it('should update metadata for existing keys without overwriting values', async () => {
variableRepository.findOne.mockResolvedValue({
id: 'var-1',
key: 'API_KEY',
encryptedValue: 'enc_existing_value',
isSecret: true,
isRequired: false,
} as AppRegistrationVariableEntity);
await service.syncVariableSchemas('reg-1', {
API_KEY: {
description: 'Updated description',
isSecret: false,
isRequired: true,
},
});
expect(variableRepository.update).toHaveBeenCalledWith('var-1', {
description: 'Updated description',
isSecret: false,
isRequired: true,
});
expect(variableRepository.save).not.toHaveBeenCalled();
});
it('should delete variables not in schema', async () => {
variableRepository.findOne.mockResolvedValue(null);
variableRepository.create.mockImplementation(
(entity) => entity as AppRegistrationVariableEntity,
);
variableRepository.save.mockImplementation(
async (entity) => entity as AppRegistrationVariableEntity,
);
await service.syncVariableSchemas('reg-1', {
KEPT_KEY: { description: 'stays' },
});
expect(variableRepository.delete).toHaveBeenCalledWith(
expect.objectContaining({
appRegistrationId: 'reg-1',
}),
);
});
});
describe('findOneByClientId', () => {
it('should return null when client not found', async () => {
appRegistrationRepository.findOne.mockResolvedValue(null);
const result = await service.findOneByClientId('nonexistent');
expect(result).toBeNull();
});
it('should return registration by client ID', async () => {
const mockRegistration = {
id: 'reg-1',
clientId: 'chrome',
} as AppRegistrationEntity;
appRegistrationRepository.findOne.mockResolvedValue(
mockRegistration,
);
const result = await service.findOneByClientId('chrome');
expect(result).toEqual(mockRegistration);
});
});
});
@@ -0,0 +1,25 @@
import {
ALL_OAUTH_SCOPES,
OAUTH_SCOPE_DESCRIPTIONS,
OAUTH_SCOPES,
} from 'src/engine/core-modules/app-registration/constants/oauth-scopes';
describe('OAuth Scopes', () => {
it('should have all scopes defined', () => {
expect(ALL_OAUTH_SCOPES).toContain('api');
expect(ALL_OAUTH_SCOPES).toContain('profile');
expect(ALL_OAUTH_SCOPES).toHaveLength(2);
});
it('should have descriptions for all scopes', () => {
for (const scope of ALL_OAUTH_SCOPES) {
expect(OAUTH_SCOPE_DESCRIPTIONS[scope]).toBeDefined();
expect(OAUTH_SCOPE_DESCRIPTIONS[scope].length).toBeGreaterThan(0);
}
});
it('should have consistent keys and values', () => {
expect(OAUTH_SCOPES.API).toBe('api');
expect(OAUTH_SCOPES.PROFILE).toBe('profile');
});
});
@@ -0,0 +1,63 @@
import { Injectable } from '@nestjs/common';
import crypto from 'crypto';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
const AUTH_TAG_LENGTH = 16;
const HKDF_CONTEXT = 'app-registration-variable';
@Injectable()
export class AppRegistrationEncryptionService {
private encryptionKey: Buffer;
constructor(private readonly twentyConfigService: TwentyConfigService) {
const appSecret = this.twentyConfigService.get('APP_SECRET');
this.encryptionKey = Buffer.from(
crypto.hkdfSync('sha256', appSecret, '', HKDF_CONTEXT, 32),
);
}
encrypt(plaintext: string): string {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, this.encryptionKey, iv, {
authTagLength: AUTH_TAG_LENGTH,
});
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
// Format: base64(iv + authTag + ciphertext)
return Buffer.concat([iv, authTag, encrypted]).toString('base64');
}
decrypt(encryptedBase64: string): string {
if (!encryptedBase64) {
return '';
}
const data = Buffer.from(encryptedBase64, 'base64');
const iv = data.subarray(0, IV_LENGTH);
const authTag = data.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
const ciphertext = data.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
const decipher = crypto.createDecipheriv(
ALGORITHM,
this.encryptionKey,
iv,
{ authTagLength: AUTH_TAG_LENGTH },
);
decipher.setAuthTag(authTag);
return decipher.update(ciphertext) + decipher.final('utf8');
}
}
@@ -0,0 +1,54 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { AppRegistrationEntity } from 'src/engine/core-modules/app-registration/app-registration.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
@Injectable()
export class AppRegistrationIdentifierGuardService {
constructor(
@InjectRepository(AppRegistrationEntity)
private readonly appRegistrationRepository: Repository<AppRegistrationEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
) {}
// Validates that a universalIdentifier being created/used by an application
// is not claimed by a different AppRegistration.
// Grandfather policy: applications without appRegistrationId are not checked.
async validateUniversalIdentifierOwnership(params: {
universalIdentifier: string;
applicationId: string;
}): Promise<{ isValid: boolean; errorMessage?: string }> {
const application = await this.applicationRepository.findOne({
where: { id: params.applicationId },
});
if (!isDefined(application?.appRegistrationId)) {
return { isValid: true };
}
const ownerRegistration = await this.appRegistrationRepository.findOne({
where: { universalIdentifier: params.universalIdentifier },
});
if (!isDefined(ownerRegistration)) {
return { isValid: true };
}
if (ownerRegistration.id !== application.appRegistrationId) {
return {
isValid: false,
errorMessage:
`Universal identifier '${params.universalIdentifier}' is claimed by ` +
`app registration '${ownerRegistration.name}' (${ownerRegistration.id}), ` +
`but application '${application.name}' is linked to a different registration`,
};
}
return { isValid: true };
}
}
@@ -0,0 +1,108 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { AppRegistrationEncryptionService } from 'src/engine/core-modules/app-registration/app-registration-encryption.service';
import { AppRegistrationVariableEntity } from 'src/engine/core-modules/app-registration/app-registration-variable.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationVariableEntity } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
// Resolution order: workspace-level override > server-level default
@Injectable()
export class AppRegistrationVariableResolutionService {
constructor(
@InjectRepository(ApplicationVariableEntity)
private readonly applicationVariableRepository: Repository<ApplicationVariableEntity>,
@InjectRepository(AppRegistrationVariableEntity)
private readonly appRegistrationVariableRepository: Repository<AppRegistrationVariableEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
private readonly appRegistrationEncryption: AppRegistrationEncryptionService,
private readonly secretEncryptionService: SecretEncryptionService,
) {}
async resolveVariable(
applicationId: string,
key: string,
): Promise<string | undefined> {
const workspaceVariable = await this.applicationVariableRepository.findOne({
where: { applicationId, key },
});
if (isDefined(workspaceVariable)) {
if (workspaceVariable.isSecret) {
return this.secretEncryptionService.decrypt(workspaceVariable.value);
}
return workspaceVariable.value;
}
const application = await this.applicationRepository.findOne({
where: { id: applicationId },
});
if (!isDefined(application?.appRegistrationId)) {
return undefined;
}
const serverVariable = await this.appRegistrationVariableRepository.findOne(
{
where: {
appRegistrationId: application.appRegistrationId,
key,
},
},
);
if (!isDefined(serverVariable)) {
return undefined;
}
return this.appRegistrationEncryption.decrypt(
serverVariable.encryptedValue,
);
}
async resolveAllVariables(
applicationId: string,
): Promise<Record<string, string>> {
const result: Record<string, string> = {};
const application = await this.applicationRepository.findOne({
where: { id: applicationId },
});
if (isDefined(application?.appRegistrationId)) {
const serverVariables = await this.appRegistrationVariableRepository.find(
{
where: { appRegistrationId: application.appRegistrationId },
},
);
for (const variable of serverVariables) {
result[variable.key] = this.appRegistrationEncryption.decrypt(
variable.encryptedValue,
);
}
}
const workspaceVariables = await this.applicationVariableRepository.find({
where: { applicationId },
});
for (const variable of workspaceVariables) {
if (variable.isSecret) {
result[variable.key] = this.secretEncryptionService.decrypt(
variable.value,
);
} else {
result[variable.key] = variable.value;
}
}
return result;
}
}
@@ -0,0 +1,76 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { AppRegistrationEntity } from 'src/engine/core-modules/app-registration/app-registration.entity';
@Entity({ name: 'appRegistrationVariable', schema: 'core' })
@ObjectType('AppRegistrationVariable')
@Unique('IDX_APP_REGISTRATION_VARIABLE_KEY_APP_REGISTRATION_ID_UNIQUE', [
'key',
'appRegistrationId',
])
@Index('IDX_APP_REGISTRATION_VARIABLE_APP_REGISTRATION_ID', [
'appRegistrationId',
])
export class AppRegistrationVariableEntity {
@IDField(() => UUIDScalarType)
@PrimaryGeneratedColumn('uuid')
id: string;
@Field()
@Column({ nullable: false, type: 'text' })
key: string;
@Column({ nullable: false, type: 'text', default: '' })
encryptedValue: string;
@Field()
@Column({ nullable: false, type: 'text', default: '' })
description: string;
@Field()
@Column({ nullable: false, type: 'boolean', default: true })
isSecret: boolean;
@Field()
@Column({ nullable: false, type: 'boolean', default: false })
isRequired: boolean;
@Field()
get isFilled(): boolean {
return this.encryptedValue !== '';
}
@Column({ nullable: false, type: 'uuid' })
appRegistrationId: string;
@ManyToOne(
() => AppRegistrationEntity,
(appRegistration) => appRegistration.variables,
{ onDelete: 'CASCADE', nullable: false },
)
@JoinColumn({ name: 'appRegistrationId' })
appRegistration: Relation<AppRegistrationEntity>;
@Field()
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@Field()
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}
@@ -0,0 +1,105 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
type Relation,
UpdateDateColumn,
} from 'typeorm';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { AppRegistrationVariableEntity } from 'src/engine/core-modules/app-registration/app-registration-variable.entity';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
@Entity({ name: 'appRegistration', schema: 'core' })
@ObjectType('AppRegistration')
@Index('IDX_APP_REGISTRATION_UNIVERSAL_IDENTIFIER_UNIQUE', ['universalIdentifier'], {
unique: true,
where: '"deletedAt" IS NULL',
})
@Index('IDX_APP_REGISTRATION_CLIENT_ID_UNIQUE', ['clientId'], {
unique: true,
where: '"deletedAt" IS NULL',
})
@Index('IDX_APP_REGISTRATION_CREATED_BY_USER_ID', ['createdByUserId'])
export class AppRegistrationEntity {
@IDField(() => UUIDScalarType)
@PrimaryGeneratedColumn('uuid')
id: string;
@Field()
@Column({ nullable: false, type: 'uuid' })
universalIdentifier: string;
@Field()
@Column({ nullable: false, type: 'text' })
name: string;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
description: string | null;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
logoUrl: string | null;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
author: string | null;
@Field()
@Column({ nullable: false, type: 'text' })
clientId: string;
@Column({ nullable: true, type: 'text' })
clientSecretHash: string | null;
@Field(() => [String])
@Column({ type: 'text', array: true, default: '{}' })
redirectUris: string[];
@Field(() => [String])
@Column({ type: 'text', array: true, default: '{}' })
scopes: string[];
@Column({ nullable: true, type: 'uuid' })
createdByUserId: string | null;
@ManyToOne(() => UserEntity, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'createdByUserId' })
createdByUser: Relation<UserEntity> | null;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
websiteUrl: string | null;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
termsUrl: string | null;
@OneToMany(
() => AppRegistrationVariableEntity,
(variable) => variable.appRegistration,
{ onDelete: 'CASCADE' },
)
variables: Relation<AppRegistrationVariableEntity[]>;
@Field()
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@Field()
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt: Date | null;
}
@@ -0,0 +1,53 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export enum AppRegistrationExceptionCode {
APP_REGISTRATION_NOT_FOUND = 'APP_REGISTRATION_NOT_FOUND',
UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED = 'UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED',
CLIENT_ID_ALREADY_EXISTS = 'CLIENT_ID_ALREADY_EXISTS',
INVALID_SCOPE = 'INVALID_SCOPE',
INVALID_REDIRECT_URI = 'INVALID_REDIRECT_URI',
INVALID_INPUT = 'INVALID_INPUT',
VARIABLE_NOT_FOUND = 'VARIABLE_NOT_FOUND',
}
const getExceptionUserFriendlyMessage = (
code: AppRegistrationExceptionCode,
) => {
switch (code) {
case AppRegistrationExceptionCode.APP_REGISTRATION_NOT_FOUND:
return msg`App registration not found.`;
case AppRegistrationExceptionCode.UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED:
return msg`This universal identifier is already claimed by another registration.`;
case AppRegistrationExceptionCode.CLIENT_ID_ALREADY_EXISTS:
return msg`This client ID already exists.`;
case AppRegistrationExceptionCode.INVALID_SCOPE:
return msg`One or more requested scopes are invalid.`;
case AppRegistrationExceptionCode.INVALID_REDIRECT_URI:
return msg`One or more redirect URIs are invalid.`;
case AppRegistrationExceptionCode.INVALID_INPUT:
return msg`Invalid input provided.`;
case AppRegistrationExceptionCode.VARIABLE_NOT_FOUND:
return msg`App registration variable not found.`;
default:
assertUnreachable(code);
}
};
export class AppRegistrationException extends CustomException<AppRegistrationExceptionCode> {
constructor(
message: string,
code: AppRegistrationExceptionCode,
{
userFriendlyMessage,
}: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ?? getExceptionUserFriendlyMessage(code),
});
}
}
@@ -0,0 +1,41 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppRegistrationVariableEntity } from 'src/engine/core-modules/app-registration/app-registration-variable.entity';
import { AppRegistrationEncryptionService } from 'src/engine/core-modules/app-registration/app-registration-encryption.service';
import { AppRegistrationIdentifierGuardService } from 'src/engine/core-modules/app-registration/app-registration-identifier-guard.service';
import { AppRegistrationVariableResolutionService } from 'src/engine/core-modules/app-registration/app-registration-variable-resolution.service';
import { AppRegistrationEntity } from 'src/engine/core-modules/app-registration/app-registration.entity';
import { AppRegistrationResolver } from 'src/engine/core-modules/app-registration/app-registration.resolver';
import { AppRegistrationService } from 'src/engine/core-modules/app-registration/app-registration.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationVariableEntity } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
imports: [
TypeOrmModule.forFeature([
AppRegistrationEntity,
AppRegistrationVariableEntity,
ApplicationEntity,
ApplicationVariableEntity,
]),
SecretEncryptionModule,
PermissionsModule,
],
providers: [
AppRegistrationService,
AppRegistrationEncryptionService,
AppRegistrationVariableResolutionService,
AppRegistrationIdentifierGuardService,
AppRegistrationResolver,
],
exports: [
AppRegistrationService,
AppRegistrationEncryptionService,
AppRegistrationVariableResolutionService,
AppRegistrationIdentifierGuardService,
],
})
export class AppRegistrationModule {}
@@ -0,0 +1,172 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { AppRegistrationVariableEntity } from 'src/engine/core-modules/app-registration/app-registration-variable.entity';
import { AppRegistrationEntity } from 'src/engine/core-modules/app-registration/app-registration.entity';
import { AppRegistrationService } from 'src/engine/core-modules/app-registration/app-registration.service';
import { AppRegistrationStatsOutput } from 'src/engine/core-modules/app-registration/dtos/app-registration-stats.output';
import { CreateAppRegistrationInput } from 'src/engine/core-modules/app-registration/dtos/create-app-registration.input';
import { CreateAppRegistrationOutput } from 'src/engine/core-modules/app-registration/dtos/create-app-registration.output';
import { CreateAppRegistrationVariableInput } from 'src/engine/core-modules/app-registration/dtos/create-app-registration-variable.input';
import { RotateClientSecretOutput } from 'src/engine/core-modules/app-registration/dtos/rotate-client-secret.output';
import { UpdateAppRegistrationInput } from 'src/engine/core-modules/app-registration/dtos/update-app-registration.input';
import { UpdateAppRegistrationVariableInput } from 'src/engine/core-modules/app-registration/dtos/update-app-registration-variable.input';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@UsePipes(ResolverValidationPipe)
@MetadataResolver()
@UseFilters(
AuthGraphqlApiExceptionFilter,
PreventNestToAutoLogGraphqlErrorsFilter,
)
export class AppRegistrationResolver {
constructor(
private readonly appRegistrationService: AppRegistrationService,
) {}
@Query(() => AppRegistrationEntity, { nullable: true })
async findAppRegistrationByClientId(
@Args('clientId') clientId: string,
): Promise<AppRegistrationEntity | null> {
return this.appRegistrationService.findOneByClientId(clientId);
}
@UseGuards(WorkspaceAuthGuard)
@Query(() => AppRegistrationEntity, { nullable: true })
async findAppRegistrationByUniversalIdentifier(
@Args('universalIdentifier') universalIdentifier: string,
): Promise<AppRegistrationEntity | null> {
return this.appRegistrationService.findOneByUniversalIdentifier(
universalIdentifier,
);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Query(() => [AppRegistrationEntity])
async findManyAppRegistrations(): Promise<AppRegistrationEntity[]> {
return this.appRegistrationService.findMany();
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Query(() => AppRegistrationEntity)
async findOneAppRegistration(
@Args('id') id: string,
): Promise<AppRegistrationEntity> {
return this.appRegistrationService.findOneById(id);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Query(() => AppRegistrationStatsOutput)
async findAppRegistrationStats(
@Args('id') id: string,
): Promise<AppRegistrationStatsOutput> {
return this.appRegistrationService.getStats(id);
}
@UseGuards(WorkspaceAuthGuard)
@Mutation(() => CreateAppRegistrationOutput)
async createAppRegistration(
@Args('input') input: CreateAppRegistrationInput,
@AuthUser({ allowUndefined: true }) user: UserEntity | undefined,
): Promise<CreateAppRegistrationOutput> {
return this.appRegistrationService.create(input, user?.id ?? null);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => AppRegistrationEntity)
async updateAppRegistration(
@Args('input') input: UpdateAppRegistrationInput,
): Promise<AppRegistrationEntity> {
return this.appRegistrationService.update(input);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => Boolean)
async deleteAppRegistration(
@Args('id') id: string,
): Promise<boolean> {
return this.appRegistrationService.delete(id);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => RotateClientSecretOutput)
async rotateAppRegistrationClientSecret(
@Args('id') id: string,
): Promise<RotateClientSecretOutput> {
const clientSecret =
await this.appRegistrationService.rotateClientSecret(id);
return { clientSecret };
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Query(() => [AppRegistrationVariableEntity])
async findAppRegistrationVariables(
@Args('appRegistrationId') appRegistrationId: string,
): Promise<AppRegistrationVariableEntity[]> {
return this.appRegistrationService.findVariables(appRegistrationId);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => AppRegistrationVariableEntity)
async createAppRegistrationVariable(
@Args('input') input: CreateAppRegistrationVariableInput,
): Promise<AppRegistrationVariableEntity> {
return this.appRegistrationService.createVariable(input);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => AppRegistrationVariableEntity)
async updateAppRegistrationVariable(
@Args('input') input: UpdateAppRegistrationVariableInput,
): Promise<AppRegistrationVariableEntity> {
return this.appRegistrationService.updateVariable(input);
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => Boolean)
async deleteAppRegistrationVariable(
@Args('id') id: string,
): Promise<boolean> {
return this.appRegistrationService.deleteVariable(id);
}
}
@@ -0,0 +1,349 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import * as bcrypt from 'bcrypt';
import crypto from 'crypto';
import { type ServerVariables } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { In, Not, type Repository } from 'typeorm';
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { v4 } from 'uuid';
import { AppRegistrationVariableEntity } from 'src/engine/core-modules/app-registration/app-registration-variable.entity';
import { AppRegistrationEntity } from 'src/engine/core-modules/app-registration/app-registration.entity';
import {
AppRegistrationException,
AppRegistrationExceptionCode,
} from 'src/engine/core-modules/app-registration/app-registration.exception';
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/app-registration/constants/oauth-scopes';
import { type AppRegistrationStatsOutput } from 'src/engine/core-modules/app-registration/dtos/app-registration-stats.output';
import { type CreateAppRegistrationInput } from 'src/engine/core-modules/app-registration/dtos/create-app-registration.input';
import { type CreateAppRegistrationVariableInput } from 'src/engine/core-modules/app-registration/dtos/create-app-registration-variable.input';
import { type UpdateAppRegistrationInput } from 'src/engine/core-modules/app-registration/dtos/update-app-registration.input';
import { type UpdateAppRegistrationVariableInput } from 'src/engine/core-modules/app-registration/dtos/update-app-registration-variable.input';
import { AppRegistrationEncryptionService } from 'src/engine/core-modules/app-registration/app-registration-encryption.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
const BCRYPT_SALT_ROUNDS = 10;
@Injectable()
export class AppRegistrationService {
constructor(
@InjectRepository(AppRegistrationEntity)
private readonly appRegistrationRepository: Repository<AppRegistrationEntity>,
@InjectRepository(AppRegistrationVariableEntity)
private readonly variableRepository: Repository<AppRegistrationVariableEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
private readonly encryptionService: AppRegistrationEncryptionService,
) {}
async findMany(): Promise<AppRegistrationEntity[]> {
return this.appRegistrationRepository.find({
order: { createdAt: 'DESC' },
});
}
async findOneById(id: string): Promise<AppRegistrationEntity> {
const registration = await this.appRegistrationRepository.findOne({
where: { id },
});
if (!registration) {
throw new AppRegistrationException(
`App registration with id ${id} not found`,
AppRegistrationExceptionCode.APP_REGISTRATION_NOT_FOUND,
);
}
return registration;
}
async findOneByClientId(
clientId: string,
): Promise<AppRegistrationEntity | null> {
return this.appRegistrationRepository.findOne({
where: { clientId },
});
}
async findOneByUniversalIdentifier(
universalIdentifier: string,
): Promise<AppRegistrationEntity | null> {
return this.appRegistrationRepository.findOne({
where: { universalIdentifier },
});
}
async create(
input: CreateAppRegistrationInput,
createdByUserId: string | null,
): Promise<{ appRegistration: AppRegistrationEntity; clientSecret: string }> {
const universalIdentifier = input.universalIdentifier ?? v4();
const existingByUid =
await this.findOneByUniversalIdentifier(universalIdentifier);
if (existingByUid) {
throw new AppRegistrationException(
`Universal identifier ${universalIdentifier} is already claimed`,
AppRegistrationExceptionCode.UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED,
);
}
if (isDefined(input.scopes)) {
this.validateScopes(input.scopes);
}
const clientId = v4();
const { clientSecret, clientSecretHash } =
await this.generateClientSecret();
const appRegistration = this.appRegistrationRepository.create({
universalIdentifier,
name: input.name,
description: input.description ?? null,
logoUrl: input.logoUrl ?? null,
author: input.author ?? null,
clientId,
clientSecretHash,
redirectUris: input.redirectUris ?? [],
scopes: input.scopes ?? [],
createdByUserId,
websiteUrl: input.websiteUrl ?? null,
termsUrl: input.termsUrl ?? null,
});
const saved = await this.appRegistrationRepository.save(appRegistration);
return { appRegistration: saved, clientSecret };
}
async update(
input: UpdateAppRegistrationInput,
): Promise<AppRegistrationEntity> {
await this.findOneById(input.id);
if (isDefined(input.scopes)) {
this.validateScopes(input.scopes);
}
const updateData: QueryDeepPartialEntity<AppRegistrationEntity> = {};
if (isDefined(input.name)) updateData.name = input.name;
if (isDefined(input.description)) updateData.description = input.description;
if (isDefined(input.logoUrl)) updateData.logoUrl = input.logoUrl;
if (isDefined(input.author)) updateData.author = input.author;
if (isDefined(input.redirectUris)) updateData.redirectUris = input.redirectUris;
if (isDefined(input.scopes)) updateData.scopes = input.scopes;
if (isDefined(input.websiteUrl)) updateData.websiteUrl = input.websiteUrl;
if (isDefined(input.termsUrl)) updateData.termsUrl = input.termsUrl;
await this.appRegistrationRepository.update(input.id, updateData);
return this.findOneById(input.id);
}
async delete(id: string): Promise<boolean> {
await this.findOneById(id);
await this.appRegistrationRepository.softDelete(id);
return true;
}
async rotateClientSecret(id: string): Promise<string> {
await this.findOneById(id);
const { clientSecret, clientSecretHash } =
await this.generateClientSecret();
await this.appRegistrationRepository.update(id, { clientSecretHash });
return clientSecret;
}
async verifyClientSecret(
registration: AppRegistrationEntity,
clientSecret: string,
): Promise<boolean> {
if (!registration.clientSecretHash) {
return false;
}
return bcrypt.compare(clientSecret, registration.clientSecretHash);
}
// Variable operations
async findVariables(
appRegistrationId: string,
): Promise<AppRegistrationVariableEntity[]> {
return this.variableRepository.find({
where: { appRegistrationId },
order: { key: 'ASC' },
});
}
async createVariable(
input: CreateAppRegistrationVariableInput,
): Promise<AppRegistrationVariableEntity> {
await this.findOneById(input.appRegistrationId);
const encryptedValue = this.encryptionService.encrypt(input.value);
const variable = this.variableRepository.create({
appRegistrationId: input.appRegistrationId,
key: input.key,
encryptedValue,
description: input.description ?? '',
isSecret: input.isSecret ?? true,
});
return this.variableRepository.save(variable);
}
async updateVariable(
input: UpdateAppRegistrationVariableInput,
): Promise<AppRegistrationVariableEntity> {
const variable = await this.variableRepository.findOne({
where: { id: input.id },
});
if (!variable) {
throw new AppRegistrationException(
`Variable with id ${input.id} not found`,
AppRegistrationExceptionCode.VARIABLE_NOT_FOUND,
);
}
const updateData: QueryDeepPartialEntity<AppRegistrationVariableEntity> = {};
if (isDefined(input.value)) {
updateData.encryptedValue = this.encryptionService.encrypt(input.value);
}
if (isDefined(input.description)) {
updateData.description = input.description;
}
await this.variableRepository.update(input.id, updateData);
return this.variableRepository.findOneOrFail({ where: { id: input.id } });
}
async deleteVariable(id: string): Promise<boolean> {
const variable = await this.variableRepository.findOne({
where: { id },
});
if (!variable) {
throw new AppRegistrationException(
`Variable with id ${id} not found`,
AppRegistrationExceptionCode.VARIABLE_NOT_FOUND,
);
}
await this.variableRepository.delete(id);
return true;
}
// Syncs variable schemas from manifest: creates missing, updates metadata, removes stale
async syncVariableSchemas(
appRegistrationId: string,
serverVariables: ServerVariables,
): Promise<void> {
const declaredKeys = Object.keys(serverVariables);
for (const [key, schema] of Object.entries(serverVariables)) {
const existing = await this.variableRepository.findOne({
where: { appRegistrationId, key },
});
if (existing) {
await this.variableRepository.update(existing.id, {
description: schema.description ?? '',
isSecret: schema.isSecret ?? true,
isRequired: schema.isRequired ?? false,
});
} else {
await this.variableRepository.save(
this.variableRepository.create({
appRegistrationId,
key,
encryptedValue: '',
description: schema.description ?? '',
isSecret: schema.isSecret ?? true,
isRequired: schema.isRequired ?? false,
}),
);
}
}
if (declaredKeys.length > 0) {
await this.variableRepository.delete({
appRegistrationId,
key: Not(In(declaredKeys)),
});
} else {
await this.variableRepository.delete({ appRegistrationId });
}
}
async getStats(
appRegistrationId: string,
): Promise<AppRegistrationStatsOutput> {
await this.findOneById(appRegistrationId);
const installs = await this.applicationRepository.find({
where: { appRegistrationId },
select: ['version'],
});
const versionCounts = new Map<string, number>();
for (const install of installs) {
const version = install.version ?? 'unknown';
versionCounts.set(version, (versionCounts.get(version) ?? 0) + 1);
}
const versionDistribution = Array.from(versionCounts.entries())
.map(([version, count]) => ({ version, count }))
.sort((a, b) => b.count - a.count);
const latestVersion = versionDistribution[0]?.version ?? null;
return {
activeInstalls: installs.length,
latestVersion,
versionDistribution,
};
}
private async generateClientSecret(): Promise<{
clientSecret: string;
clientSecretHash: string;
}> {
const clientSecret = crypto.randomBytes(32).toString('hex');
const clientSecretHash = await bcrypt.hash(
clientSecret,
BCRYPT_SALT_ROUNDS,
);
return { clientSecret, clientSecretHash };
}
private validateScopes(scopes: string[]): void {
const validScopes: readonly string[] = ALL_OAUTH_SCOPES;
const invalidScopes = scopes.filter(
(scope) => !validScopes.includes(scope),
);
if (invalidScopes.length > 0) {
throw new AppRegistrationException(
`Invalid scopes: ${invalidScopes.join(', ')}`,
AppRegistrationExceptionCode.INVALID_SCOPE,
);
}
}
}
@@ -0,0 +1,17 @@
// Scopes are a thin consent boundary shown to the user during OAuth authorization.
// Actual permissions are enforced by the role assigned to the application at the
// workspace level (object, field, and row-level permissions).
export const OAUTH_SCOPES = {
API: 'api',
PROFILE: 'profile',
} as const;
export type OAuthScope = (typeof OAUTH_SCOPES)[keyof typeof OAUTH_SCOPES];
export const ALL_OAUTH_SCOPES: OAuthScope[] = Object.values(OAUTH_SCOPES);
export const OAUTH_SCOPE_DESCRIPTIONS: Record<OAuthScope, string> = {
[OAUTH_SCOPES.API]:
'Access workspace data according to the assigned role',
[OAUTH_SCOPES.PROFILE]: "Read the authenticated user's profile",
};
@@ -0,0 +1,22 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class VersionDistributionEntry {
@Field()
version: string;
@Field(() => Int)
count: number;
}
@ObjectType()
export class AppRegistrationStatsOutput {
@Field(() => Int)
activeInstalls: number;
@Field(() => String, { nullable: true })
latestVersion: string | null;
@Field(() => [VersionDistributionEntry])
versionDistribution: VersionDistributionEntry[];
}
@@ -0,0 +1,28 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsBoolean, IsOptional, IsString, IsUUID } from 'class-validator';
@InputType()
export class CreateAppRegistrationVariableInput {
@Field()
@IsUUID()
appRegistrationId: string;
@Field()
@IsString()
key: string;
@Field()
@IsString()
value: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
description?: string;
@Field({ nullable: true })
@IsBoolean()
@IsOptional()
isSecret?: boolean;
}
@@ -0,0 +1,52 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsArray, IsOptional, IsString, IsUUID } from 'class-validator';
@InputType()
export class CreateAppRegistrationInput {
@Field()
@IsString()
name: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
description?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
logoUrl?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
author?: string;
@Field({ nullable: true })
@IsUUID()
@IsOptional()
universalIdentifier?: string;
@Field(() => [String], { nullable: true })
@IsArray()
@IsString({ each: true })
@IsOptional()
redirectUris?: string[];
@Field(() => [String], { nullable: true })
@IsArray()
@IsString({ each: true })
@IsOptional()
scopes?: string[];
@Field({ nullable: true })
@IsString()
@IsOptional()
websiteUrl?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
termsUrl?: string;
}
@@ -0,0 +1,12 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { AppRegistrationEntity } from 'src/engine/core-modules/app-registration/app-registration.entity';
@ObjectType()
export class CreateAppRegistrationOutput {
@Field(() => AppRegistrationEntity)
appRegistration: AppRegistrationEntity;
@Field()
clientSecret: string;
}
@@ -0,0 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class RotateClientSecretOutput {
@Field()
clientSecret: string;
}
@@ -0,0 +1,20 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsOptional, IsString, IsUUID } from 'class-validator';
@InputType()
export class UpdateAppRegistrationVariableInput {
@Field()
@IsUUID()
id: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
value?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
description?: string;
}
@@ -0,0 +1,57 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsArray,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
@InputType()
export class UpdateAppRegistrationInput {
@Field()
@IsUUID()
id: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
name?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
description?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
logoUrl?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
author?: string;
@Field(() => [String], { nullable: true })
@IsArray()
@IsString({ each: true })
@IsOptional()
redirectUris?: string[];
@Field(() => [String], { nullable: true })
@IsArray()
@IsString({ each: true })
@IsOptional()
scopes?: string[];
@Field({ nullable: true })
@IsString()
@IsOptional()
websiteUrl?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
termsUrl?: string;
}
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppRegistrationModule } from 'src/engine/core-modules/app-registration/app-registration.module';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { ApplicationDevelopmentResolver } from 'src/engine/core-modules/application/resolvers/application-development.resolver';
import { ApplicationResolver } from 'src/engine/core-modules/application/resolvers/application.resolver';
@@ -24,6 +25,7 @@ import { CodeStepBuildModule } from 'src/modules/workflow/workflow-builder/workf
@Module({
imports: [
TypeOrmModule.forFeature([FileEntity]),
AppRegistrationModule,
ApplicationModule,
ApplicationVariableEntityModule,
TokenModule,
@@ -7,6 +7,7 @@ import {
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
@@ -14,6 +15,7 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { AppRegistrationEntity } from 'src/engine/core-modules/app-registration/app-registration.entity';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { ApplicationVariableEntity } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
@@ -91,6 +93,16 @@ export class ApplicationEntity extends WorkspaceRelatedEntity {
@Column({ nullable: false, type: 'boolean', default: true })
canBeUninstalled: boolean;
@Column({ nullable: true, type: 'uuid' })
appRegistrationId: string | null;
@ManyToOne(() => AppRegistrationEntity, {
onDelete: 'SET NULL',
nullable: true,
})
@JoinColumn({ name: 'appRegistrationId' })
appRegistration: Relation<AppRegistrationEntity> | null;
@OneToMany(() => AgentEntity, (agent) => agent.application, {
onDelete: 'CASCADE',
})
@@ -8,4 +8,5 @@ export const APPLICATION_ENTITY_RELATION_PROPERTIES = [
'applicationVariables',
'packageJsonFile',
'yarnLockFile',
'appRegistration',
] as const satisfies (keyof ApplicationEntity)[];
@@ -1,6 +1,6 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
@InputType()
export class CreateApplicationInput {
@@ -28,4 +28,9 @@ export class CreateApplicationInput {
@IsNotEmpty()
@Field()
sourcePath: string;
@IsUUID()
@IsOptional()
@Field({ nullable: true })
appRegistrationId?: string;
}
@@ -5,6 +5,7 @@ import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { PackageJson } from 'type-fest';
import { AppRegistrationService } from 'src/engine/core-modules/app-registration/app-registration.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import {
ApplicationException,
@@ -38,6 +39,7 @@ export class ApplicationSyncService {
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly fileStorageService: FileStorageService,
private readonly appRegistrationService: AppRegistrationService,
) {}
public async synchronizeFromManifest({
@@ -118,14 +120,65 @@ export class ApplicationSyncService {
},
);
return await this.applicationService.update(application.id, {
const updateData: Partial<ApplicationEntity> = {
name,
description: manifest.application.description,
version: packageJson.version,
packageJsonChecksum: manifest.application.packageJsonChecksum,
yarnLockChecksum: manifest.application.yarnLockChecksum,
//availablePackages: manifest.application.availablePackages, // TODO: compute available package in dev-mode-orchestrator
};
let appRegistrationId = application.appRegistrationId;
const appRegistrationMetadata = {
name,
description: manifest.application.description,
logoUrl: manifest.application.logoUrl,
author: manifest.application.author,
websiteUrl: manifest.application.websiteUrl,
termsUrl: manifest.application.termsUrl,
};
if (!appRegistrationId) {
const existingRegistration =
await this.appRegistrationService.findOneByUniversalIdentifier(
manifest.application.universalIdentifier,
);
if (existingRegistration) {
appRegistrationId = existingRegistration.id;
} else {
const { appRegistration: newRegistration } =
await this.appRegistrationService.create(
{
...appRegistrationMetadata,
universalIdentifier: manifest.application.universalIdentifier,
},
null,
);
appRegistrationId = newRegistration.id;
this.logger.log(
`Created app registration for ${name} (${manifest.application.universalIdentifier})`,
);
}
updateData.appRegistrationId = appRegistrationId;
}
await this.appRegistrationService.update({
id: appRegistrationId,
...appRegistrationMetadata,
});
if (manifest.application.serverVariables) {
await this.appRegistrationService.syncVariableSchemas(
appRegistrationId,
manifest.application.serverVariables,
);
}
return await this.applicationService.update(application.id, updateData);
}
public async uninstallApplication({
@@ -191,9 +191,8 @@ export class MarketplaceService {
const packageJson = JSON.parse(packageJsonContent) as PackageJson;
const { application } = manifest;
const marketplaceData = application.marketplaceData;
if (!marketplaceData?.author || !marketplaceData?.category) {
if (!application.author || !application.category) {
return null;
}
@@ -258,14 +257,14 @@ export class MarketplaceService {
description: application.description ?? '',
icon: application.icon ?? 'IconApps',
version: packageJson.version ?? '0.1.0',
author: marketplaceData.author,
category: marketplaceData.category,
logo: this.resolveAssetUrl(appPath, marketplaceData.logo),
screenshots: this.resolveAssetUrls(appPath, marketplaceData.screenshots),
aboutDescription: marketplaceData.aboutDescription ?? '',
providers: marketplaceData.providers ?? [],
websiteUrl: marketplaceData.websiteUrl,
termsUrl: marketplaceData.termsUrl,
author: application.author,
category: application.category,
logo: this.resolveAssetUrl(appPath, application.logoUrl),
screenshots: this.resolveAssetUrls(appPath, application.screenshots),
aboutDescription: application.aboutDescription ?? '',
providers: application.providers ?? [],
websiteUrl: application.websiteUrl,
termsUrl: application.termsUrl,
objects,
fields,
logicFunctions,
@@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { AppRegistrationModule } from 'src/engine/core-modules/app-registration/app-registration.module';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { AppTokenService } from 'src/engine/core-modules/app-token/services/app-token.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
@@ -13,6 +14,8 @@ import { GoogleAPIsAuthController } from 'src/engine/core-modules/auth/controlle
import { GoogleAuthController } from 'src/engine/core-modules/auth/controllers/google-auth.controller';
import { MicrosoftAPIsAuthController } from 'src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller';
import { MicrosoftAuthController } from 'src/engine/core-modules/auth/controllers/microsoft-auth.controller';
import { OAuthDiscoveryController } from 'src/engine/core-modules/auth/controllers/oauth-discovery.controller';
import { OAuthTokenController } from 'src/engine/core-modules/auth/controllers/oauth-token.controller';
import { SSOAuthController } from 'src/engine/core-modules/auth/controllers/sso-auth.controller';
import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service';
import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service';
@@ -71,6 +74,7 @@ import { MessagingFolderSyncManagerModule } from 'src/modules/messaging/message-
import { AuthResolver } from './auth.resolver';
import { AuthService } from './services/auth.service';
import { OAuthService } from './services/oauth.service';
import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
@Module({
@@ -116,6 +120,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
AuditModule,
SubdomainManagerModule,
DomainServerConfigModule,
AppRegistrationModule,
ApplicationModule,
WorkspaceCacheModule,
SecureHttpClientModule,
@@ -127,6 +132,8 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
GoogleAPIsAuthController,
MicrosoftAPIsAuthController,
SSOAuthController,
OAuthTokenController,
OAuthDiscoveryController,
],
providers: [
SignInUpService,
@@ -153,6 +160,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
UpdateConnectedAccountOnReconnectService,
TransientTokenService,
AuthSsoService,
OAuthService,
],
exports: [
AccessTokenService,
@@ -0,0 +1,36 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/app-registration/constants/oauth-scopes';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@Controller('.well-known')
export class OAuthDiscoveryController {
constructor(
private readonly twentyConfigService: TwentyConfigService,
) {}
@Get('oauth-authorization-server')
@UseGuards(PublicEndpointGuard)
getAuthorizationServerMetadata() {
const serverUrl = this.twentyConfigService.get('SERVER_URL');
return {
issuer: serverUrl,
authorization_endpoint: `${serverUrl}/authorize`,
token_endpoint: `${serverUrl}/oauth/token`,
scopes_supported: ALL_OAUTH_SCOPES,
response_types_supported: ['code'],
grant_types_supported: [
'authorization_code',
'client_credentials',
'refresh_token',
],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: [
'client_secret_post',
'none',
],
};
}
}
@@ -0,0 +1,85 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { IsOptional, IsString } from 'class-validator';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
export class OAuthTokenRequestDto {
@IsString()
grant_type: string;
@IsOptional()
@IsString()
code?: string;
@IsOptional()
@IsString()
redirect_uri?: string;
@IsOptional()
@IsString()
client_id?: string;
@IsOptional()
@IsString()
client_secret?: string;
@IsOptional()
@IsString()
code_verifier?: string;
@IsOptional()
@IsString()
refresh_token?: string;
}
@Controller('oauth')
@UseFilters(AuthRestApiExceptionFilter)
export class OAuthTokenController {
constructor(private readonly oauthService: OAuthService) {}
@Post('token')
@HttpCode(HttpStatus.OK)
@UseGuards(PublicEndpointGuard)
async token(@Body() body: OAuthTokenRequestDto) {
switch (body.grant_type) {
case 'authorization_code':
return this.oauthService.exchangeAuthorizationCode({
authorizationCode: body.code ?? '',
clientId: body.client_id ?? '',
clientSecret: body.client_secret,
codeVerifier: body.code_verifier,
redirectUri: body.redirect_uri ?? '',
});
case 'client_credentials':
return this.oauthService.clientCredentialsGrant({
clientId: body.client_id ?? '',
clientSecret: body.client_secret ?? '',
});
case 'refresh_token':
return this.oauthService.refreshTokenGrant({
refreshToken: body.refresh_token ?? '',
clientId: body.client_id ?? '',
clientSecret: body.client_secret,
});
default:
return {
error: 'unsupported_grant_type',
error_description: `Grant type '${body.grant_type}' is not supported`,
};
}
}
}
@@ -13,8 +13,9 @@ import { AppPath } from 'twenty-shared/types';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
import { AppRegistrationService } from 'src/engine/core-modules/app-registration/app-registration.service';
import {
AppTokenEntity,
AppTokenType,
@@ -94,6 +95,7 @@ export class AuthService {
private readonly appTokenRepository: Repository<AppTokenEntity>,
private readonly i18nService: I18nService,
private readonly auditService: AuditService,
private readonly appRegistrationService: AppRegistrationService,
) {}
private async checkAccessAndUseInvitationOrThrow(
@@ -495,40 +497,28 @@ export class AuthService {
user: UserEntity,
workspace: WorkspaceEntity,
): Promise<AuthorizeAppOutput> {
// TODO: replace with db call to - third party app table
const apps = [
{
id: 'chrome',
name: 'Chrome Extension',
redirectUrl:
this.twentyConfigService.get('NODE_ENV') ===
NodeEnvironment.DEVELOPMENT
? authorizeAppInput.redirectUrl
: `https://${this.twentyConfigService.get(
'CHROME_EXTENSION_ID',
)}.chromiumapp.org/`,
},
];
const { clientId, codeChallenge } = authorizeAppInput;
const client = apps.find((app) => app.id === clientId);
const appRegistration =
await this.appRegistrationService.findOneByClientId(clientId);
if (!client) {
if (!appRegistration) {
throw new AuthException(
`Client not found for '${clientId}'`,
AuthExceptionCode.CLIENT_NOT_FOUND,
);
}
if (!client.redirectUrl || !authorizeAppInput.redirectUrl) {
if (!authorizeAppInput.redirectUrl) {
throw new AuthException(
`redirectUrl not found for '${clientId}'`,
`redirectUrl not provided for '${clientId}'`,
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
if (client.redirectUrl !== authorizeAppInput.redirectUrl) {
if (
!appRegistration.redirectUris.includes(authorizeAppInput.redirectUrl)
) {
throw new AuthException(
`redirectUrl mismatch for '${clientId}'`,
AuthExceptionCode.FORBIDDEN_EXCEPTION,
@@ -570,9 +560,7 @@ export class AuthService {
await this.appTokenRepository.save(token);
}
const redirectUrl = `${
client.redirectUrl ? client.redirectUrl : authorizeAppInput.redirectUrl
}?authorizationCode=${authorizationCode}`;
const redirectUrl = `${authorizeAppInput.redirectUrl}?authorizationCode=${authorizationCode}`;
return { redirectUrl };
}
@@ -1,157 +1,362 @@
// import { Injectable } from '@nestjs/common';
// import { InjectRepository } from '@nestjs/typeorm';
//
// import crypto from 'crypto';
//
// import { Repository } from 'typeorm';
//
// import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
// import {
// AuthException,
// AuthExceptionCode,
// } from 'src/engine/core-modules/auth/auth.exception';
// import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity';
// import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input';
// import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
// import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
// import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
// import { UserEntity } from 'src/engine/core-modules/user/user.entity';
// import { userValidator } from 'src/engine/core-modules/user/user.validate';
//
// @Injectable()
// export class OAuthService {
// constructor(
// @InjectRepository(UserEntity)
// private readonly userRepository: Repository<UserEntity>,
// @InjectRepository(AppTokenEntity)
// private readonly appTokenRepository: Repository<AppTokenEntity>,
// private readonly accessTokenService: AccessTokenService,
// private readonly refreshTokenService: RefreshTokenService,
// private readonly loginTokenService: LoginTokenService,
// ) {}
//
// async verifyAuthorizationCode(
// exchangeAuthCodeInput: ExchangeAuthCodeInput,
// ): Promise<ExchangeAuthCode> {
// const { authorizationCode, codeVerifier } = exchangeAuthCodeInput;
//
// if (!authorizationCode) {
// throw new AuthException(
// 'Authorization code not found',
// AuthExceptionCode.INVALID_INPUT,
// );
// }
//
// let userId = '';
//
// if (codeVerifier) {
// const authorizationCodeAppToken = await this.appTokenRepository.findOne({
// where: {
// value: authorizationCode,
// },
// });
//
// if (!authorizationCodeAppToken) {
// throw new AuthException(
// 'Authorization code does not exist',
// AuthExceptionCode.INVALID_INPUT,
// );
// }
//
// if (!(authorizationCodeAppToken.expiresAt.getTime() >= Date.now())) {
// throw new AuthException(
// 'Authorization code expired.',
// AuthExceptionCode.FORBIDDEN_EXCEPTION,
// );
// }
//
// const codeChallenge = crypto
// .createHash('sha256')
// .update(codeVerifier)
// .digest()
// .toString('base64')
// .replace(/\+/g, '-')
// .replace(/\//g, '_')
// .replace(/=/g, '');
//
// const codeChallengeAppToken = await this.appTokenRepository.findOne({
// where: {
// value: codeChallenge,
// },
// });
//
// if (!codeChallengeAppToken || !codeChallengeAppToken.userId) {
// throw new AuthException(
// 'code verifier doesnt match the challenge',
// AuthExceptionCode.FORBIDDEN_EXCEPTION,
// );
// }
//
// if (!(codeChallengeAppToken.expiresAt.getTime() >= Date.now())) {
// throw new AuthException(
// 'code challenge expired.',
// AuthExceptionCode.FORBIDDEN_EXCEPTION,
// );
// }
//
// if (codeChallengeAppToken.userId !== authorizationCodeAppToken.userId) {
// throw new AuthException(
// 'authorization code / code verifier was not created by same client',
// AuthExceptionCode.FORBIDDEN_EXCEPTION,
// );
// }
//
// if (codeChallengeAppToken.revokedAt) {
// throw new AuthException(
// 'Token has been revoked.',
// AuthExceptionCode.FORBIDDEN_EXCEPTION,
// );
// }
//
// await this.appTokenRepository.save({
// id: codeChallengeAppToken.id,
// revokedAt: new Date(),
// });
//
// userId = codeChallengeAppToken.userId;
// }
//
// const user = await this.userRepository.findOne({
// where: { id: userId },
// relations: ['defaultWorkspace'],
// });
//
// userValidator.assertIsDefinedOrThrow(
// user,
// new AuthException(
// 'User who generated the token does not exist',
// AuthExceptionCode.INVALID_INPUT,
// ),
// );
//
// if (!user.defaultWorkspace) {
// throw new AuthException(
// 'User does not have a default workspace',
// AuthExceptionCode.INVALID_DATA,
// );
// }
//
// const accessToken = await this.accessTokenService.generateAccessToken(
// user.id,
// user.defaultWorkspaceId,
// );
// const refreshToken = await this.refreshTokenService.generateRefreshToken(
// user.id,
// user.defaultWorkspaceId,
// );
// const loginToken = await this.loginTokenService.generateLoginToken(
// user.email,
// );
//
// return {
// accessToken,
// refreshToken,
// loginToken,
// };
// }
// }
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'crypto';
import { Repository } from 'typeorm';
import { AppRegistrationEntity } from 'src/engine/core-modules/app-registration/app-registration.entity';
import { AppRegistrationService } from 'src/engine/core-modules/app-registration/app-registration.service';
import {
AppTokenEntity,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
const OAUTH_ACCESS_TOKEN_EXPIRES_IN = 1800;
type OAuthTokenResponse = {
access_token: string;
token_type: string;
expires_in: number;
refresh_token?: string;
scope?: string;
};
type OAuthErrorResponse = {
error: string;
error_description: string;
};
@Injectable()
export class OAuthService {
constructor(
@InjectRepository(AppTokenEntity)
private readonly appTokenRepository: Repository<AppTokenEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
private readonly applicationTokenService: ApplicationTokenService,
private readonly appRegistrationService: AppRegistrationService,
) {}
async exchangeAuthorizationCode(params: {
authorizationCode: string;
clientId: string;
clientSecret?: string;
codeVerifier?: string;
redirectUri: string;
}): Promise<OAuthTokenResponse | OAuthErrorResponse> {
const {
authorizationCode,
clientId,
clientSecret,
codeVerifier,
redirectUri,
} = params;
if (!authorizationCode) {
return this.errorResponse(
'invalid_request',
'Authorization code is required',
);
}
const clientValidation = await this.validateClient(clientId);
if ('error' in clientValidation) {
return clientValidation;
}
const appRegistration = clientValidation;
if (
redirectUri &&
appRegistration.redirectUris.length > 0 &&
!appRegistration.redirectUris.includes(redirectUri)
) {
return this.errorResponse(
'invalid_grant',
'Redirect URI does not match any registered redirect URI',
);
}
if (clientSecret) {
const secretError = await this.validateClientSecret(
appRegistration,
clientSecret,
);
if (secretError) {
return secretError;
}
}
const authCodeToken = await this.appTokenRepository.findOne({
where: {
value: authorizationCode,
type: AppTokenType.AuthorizationCode,
},
});
if (!authCodeToken) {
return this.errorResponse(
'invalid_grant',
'Authorization code not found',
);
}
if (authCodeToken.expiresAt.getTime() < Date.now()) {
return this.errorResponse(
'invalid_grant',
'Authorization code expired',
);
}
if (codeVerifier) {
const pkceError = await this.validatePkce(codeVerifier, authCodeToken);
if (pkceError) {
return pkceError;
}
}
if (!clientSecret && !codeVerifier) {
return this.errorResponse(
'invalid_request',
'Either client_secret or code_verifier (PKCE) is required',
);
}
await this.appTokenRepository.update(authCodeToken.id, {
revokedAt: new Date(),
});
if (!authCodeToken.userId || !authCodeToken.workspaceId) {
return this.errorResponse(
'server_error',
'Authorization code is missing user or workspace context',
);
}
const application = await this.findApplicationByRegistrationAndWorkspace(
appRegistration.id,
authCodeToken.workspaceId,
);
if (!application) {
return this.errorResponse(
'server_error',
'No workspace installation found for this client',
);
}
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: {
userId: authCodeToken.userId,
workspaceId: authCodeToken.workspaceId,
},
});
const { applicationAccessToken, applicationRefreshToken } =
await this.applicationTokenService.generateApplicationTokenPair({
workspaceId: authCodeToken.workspaceId,
applicationId: application.id,
userId: authCodeToken.userId,
userWorkspaceId: userWorkspace?.id,
});
return {
access_token: applicationAccessToken.token,
token_type: 'Bearer',
expires_in: OAUTH_ACCESS_TOKEN_EXPIRES_IN,
refresh_token: applicationRefreshToken.token,
scope: appRegistration.scopes.join(' '),
};
}
async clientCredentialsGrant(params: {
clientId: string;
clientSecret: string;
}): Promise<OAuthTokenResponse | OAuthErrorResponse> {
const { clientId, clientSecret } = params;
const clientValidation = await this.validateClient(clientId);
if ('error' in clientValidation) {
return clientValidation;
}
const appRegistration = clientValidation;
const secretError = await this.validateClientSecret(
appRegistration,
clientSecret,
);
if (secretError) {
return secretError;
}
const application = await this.applicationRepository.findOne({
where: { appRegistrationId: appRegistration.id },
});
if (!application) {
return this.errorResponse(
'server_error',
'No workspace installation found for this client. Install the app in a workspace first.',
);
}
const applicationAccessToken =
await this.applicationTokenService.generateApplicationAccessToken({
workspaceId: application.workspaceId,
applicationId: application.id,
});
return {
access_token: applicationAccessToken.token,
token_type: 'Bearer',
expires_in: OAUTH_ACCESS_TOKEN_EXPIRES_IN,
scope: appRegistration.scopes.join(' '),
};
}
async refreshTokenGrant(params: {
refreshToken: string;
clientId: string;
clientSecret?: string;
}): Promise<OAuthTokenResponse | OAuthErrorResponse> {
const { refreshToken, clientId, clientSecret } = params;
const clientValidation = await this.validateClient(clientId);
if ('error' in clientValidation) {
return clientValidation;
}
const appRegistration = clientValidation;
if (clientSecret) {
const secretError = await this.validateClientSecret(
appRegistration,
clientSecret,
);
if (secretError) {
return secretError;
}
}
try {
const payload =
this.applicationTokenService.validateApplicationRefreshToken(
refreshToken,
);
const { applicationAccessToken, applicationRefreshToken } =
await this.applicationTokenService.renewApplicationTokens(payload);
return {
access_token: applicationAccessToken.token,
token_type: 'Bearer',
expires_in: OAUTH_ACCESS_TOKEN_EXPIRES_IN,
refresh_token: applicationRefreshToken.token,
scope: appRegistration.scopes.join(' '),
};
} catch {
return this.errorResponse(
'invalid_grant',
'Invalid or expired refresh token',
);
}
}
private async validateClient(
clientId: string,
): Promise<AppRegistrationEntity | OAuthErrorResponse> {
const appRegistration =
await this.appRegistrationService.findOneByClientId(clientId);
if (!appRegistration) {
return this.errorResponse('invalid_client', 'Client not found');
}
return appRegistration;
}
private async validateClientSecret(
appRegistration: AppRegistrationEntity,
clientSecret: string,
): Promise<OAuthErrorResponse | null> {
const isValid = await this.appRegistrationService.verifyClientSecret(
appRegistration,
clientSecret,
);
if (!isValid) {
return this.errorResponse('invalid_client', 'Invalid client secret');
}
return null;
}
private async validatePkce(
codeVerifier: string,
authCodeToken: AppTokenEntity,
): Promise<OAuthErrorResponse | null> {
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest()
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
const challengeToken = await this.appTokenRepository.findOne({
where: {
value: codeChallenge,
type: AppTokenType.CodeChallenge,
...(authCodeToken.userId ? { userId: authCodeToken.userId } : {}),
},
});
if (!challengeToken) {
return this.errorResponse(
'invalid_grant',
'Code verifier does not match the code challenge',
);
}
if (challengeToken.expiresAt.getTime() < Date.now()) {
return this.errorResponse('invalid_grant', 'Code challenge expired');
}
await this.appTokenRepository.update(challengeToken.id, {
revokedAt: new Date(),
});
return null;
}
private async findApplicationByRegistrationAndWorkspace(
appRegistrationId: string,
workspaceId: string,
): Promise<ApplicationEntity | null> {
return this.applicationRepository.findOne({
where: { appRegistrationId, workspaceId },
});
}
private errorResponse(
error: string,
errorDescription: string,
): OAuthErrorResponse {
return { error, error_description: errorDescription };
}
}
@@ -359,12 +359,33 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
);
}
// TODO: Token carries userId/userWorkspaceId but they are unused.
// Compute the intersection of user and application permissions instead.
return {
application,
workspace,
};
const context: AuthContext = { application, workspace };
// When the token carries user context (e.g. OAuth authorization code flow),
// populate user fields so downstream resolvers can identify the acting user.
if (payload.userId) {
const user = await this.userRepository.findOne({
where: { id: payload.userId },
});
if (isDefined(user)) {
context.user = user;
}
}
if (payload.userWorkspaceId) {
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: { id: payload.userWorkspaceId },
relations: ['user', 'workspace'],
});
if (isDefined(userWorkspace)) {
context.userWorkspace = userWorkspace;
context.userWorkspaceId = userWorkspace.id;
}
}
return context;
}
private isLegacyApiKeyPayload(
@@ -7,6 +7,7 @@ import { ActorModule } from 'src/engine/core-modules/actor/actor.module';
import { AdminPanelModule } from 'src/engine/core-modules/admin-panel/admin-panel.module';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { AppTokenModule } from 'src/engine/core-modules/app-token/app-token.module';
import { AppRegistrationModule } from 'src/engine/core-modules/app-registration/app-registration.module';
import { ApplicationSyncModule } from 'src/engine/core-modules/application/application-sync.module';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { EnvironmentModule } from 'src/engine/core-modules/environment/environment.module';
@@ -88,6 +89,7 @@ import { FileModule } from './file/file.module';
FileModule,
RowLevelPermissionModule,
OpenApiModule,
AppRegistrationModule,
ApplicationModule,
ApplicationSyncModule,
AppTokenModule,
@@ -5,20 +5,18 @@
"displayName": "Data Enrichment",
"description": "Enrich your data easily. Choose your provider.",
"icon": "IconSparkles",
"marketplaceData": {
"author": "Cosmos Labs",
"category": "Data",
"logo": "assets/logo.png",
"screenshots": [
"assets/screenshot-1.png",
"assets/screenshot-2.png",
"assets/screenshot-3.png"
],
"aboutDescription": "Enhance your workspace with automated data intelligence. This app monitors your new records and automatically populates missing details such as job titles, company size, social profiles, and industry insights.",
"providers": ["Clearbit", "Apollo", "Hunter.io"],
"websiteUrl": "https://google.com",
"termsUrl": "https://google.com"
}
"author": "Cosmos Labs",
"category": "Data",
"logoUrl": "assets/logo.png",
"screenshots": [
"assets/screenshot-1.png",
"assets/screenshot-2.png",
"assets/screenshot-3.png"
],
"aboutDescription": "Enhance your workspace with automated data intelligence. This app monitors your new records and automatically populates missing details such as job titles, company size, social profiles, and industry insights.",
"providers": ["Clearbit", "Apollo", "Hunter.io"],
"websiteUrl": "https://google.com",
"termsUrl": "https://google.com"
},
"entities": {
"objects": [],
@@ -4,4 +4,5 @@ export enum AuthProviderEnum {
Password = 'password',
SSO = 'sso',
Impersonation = 'impersonation',
OAuth = 'oauth',
}
@@ -1,17 +1,7 @@
import { type ApplicationVariables } from './applicationVariablesType';
import { type ServerVariables } from './serverVariablesType';
import { type SyncableEntityOptions } from './syncableEntityOptionsType';
export type ApplicationMarketplaceData = {
author?: string;
category?: string;
logo?: string;
screenshots?: string[];
aboutDescription?: string;
providers?: string[];
websiteUrl?: string;
termsUrl?: string;
};
export type ApplicationManifest = SyncableEntityOptions & {
defaultRoleUniversalIdentifier: string;
postInstallLogicFunctionUniversalIdentifier?: string;
@@ -19,7 +9,15 @@ export type ApplicationManifest = SyncableEntityOptions & {
description: string;
icon?: string;
applicationVariables?: ApplicationVariables;
marketplaceData?: ApplicationMarketplaceData;
serverVariables?: ServerVariables;
author?: string;
logoUrl?: string;
category?: string;
screenshots?: string[];
aboutDescription?: string;
providers?: string[];
websiteUrl?: string;
termsUrl?: string;
packageJsonChecksum: string | null;
yarnLockChecksum: string | null;
apiClientChecksum: string | null;
@@ -7,10 +7,7 @@
* |___/
*/
export type {
ApplicationMarketplaceData,
ApplicationManifest,
} from './applicationType';
export type { ApplicationManifest } from './applicationType';
export type { ApplicationVariables } from './applicationVariablesType';
export type { AssetManifest } from './assetManifestType';
export { API_CLIENT_DIR } from './constants/ApiClientDirectory';
@@ -52,6 +49,7 @@ export type {
FieldPermissionManifest,
RoleManifest,
} from './roleManifestType';
export type { ServerVariables } from './serverVariablesType';
export type { SkillManifest } from './skillManifestType';
export type { SyncableEntityOptions } from './syncableEntityOptionsType';
export type {
@@ -0,0 +1,7 @@
type ServerVariableSchema = {
description?: string;
isSecret?: boolean;
isRequired?: boolean;
};
export type ServerVariables = Record<string, ServerVariableSchema>;
@@ -38,6 +38,7 @@ export enum SettingsPath {
ApplicationDetail = 'applications/:applicationId',
ApplicationLogicFunctionDetail = 'applications/:applicationId/logicFunctions/:logicFunctionId',
AvailableApplicationDetail = 'applications/available/:availableApplicationId',
AppRegistrationDetail = 'applications/registrations/:appRegistrationId',
LogicFunctions = 'functions',
NewLogicFunction = 'functions/new',
LogicFunctionDetail = 'functions/:logicFunctionId',