Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(server): Add ApiUrl + ServerUrl env + allow usage of https #8579

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
2 changes: 2 additions & 0 deletions packages/twenty-server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,5 @@ ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access
# PG_SSL_ALLOW_SELF_SIGNED=true
# SESSION_STORE_SECRET=replace_me_with_a_random_string_session
# ENTERPRISE_KEY=replace_me_with_a_valid_enterprise_key
# SSL_KEY_PATH="./certs/your-cert.key"
# SSL_CERT_PATH="./certs/your-cert.crt"
78 changes: 78 additions & 0 deletions packages/twenty-server/scripts/ssl-generation/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Local SSL Certificate Generation Script

This Bash script helps generate self-signed SSL certificates for local development. It uses OpenSSL to create a root certificate authority, a domain certificate, and configures them for local usage.

## Features
- Generates a private key and root certificate.
- Creates a signed certificate for a specified domain.
- Adds the root certificate to the macOS keychain for trusted usage (macOS only).
- Customizable with default values for easier use.

## Requirements
- OpenSSL

## Usage

### Running the Script

To generate certificates using the default values:

```sh
./script.sh
```

### Specifying Custom Values

1. **Domain Name**: Specify the domain name for the certificate. Default is `localhost.com`.
2. **Root Certificate Name**: Specify a name for the root certificate. Default is `myRootCertificate`.
3. **Validity Days**: Specify the number of days the certificate is valid for. Default is `825` days.

#### Examples:

1. **Using Default Values**:
```sh
./script.sh
```

2. **Custom Domain Name**:
```sh
./script.sh example.com
```

3. **Custom Domain Name and Root Certificate Name**:
```sh
./script.sh example.com customRootCertificate
```

4. **Custom Domain Name, Root Certificate Name, and Validity Days**:
```sh
./script.sh example.com customRootCertificate 1095
```

## Script Details

1. **Check if OpenSSL is Installed**: Ensures OpenSSL is installed before executing.
2. **Create Directory for Certificates**: Uses `~/certs/{domain}`.
3. **Generate Root Certificate**: Creates a root private key and certificate.
4. **Add Root Certificate to macOS Keychain**: Adds root certificate to macOS trusted store (requires admin privileges).
5. **Generate Domain Key**: Produces a private key for the domain.
6. **Create CSR**: Generates a Certificate Signing Request for the domain.
7. **Generate Signed Certificate**: Signs the domain certificate with the root certificate.

## Output Files

The generated files are stored in `~/certs/{domain}`:

- **Root certificate key**: `{root_cert_name}.key`
- **Root certificate**: `{root_cert_name}.pem`
- **Domain private key**: `{domain}.key`
- **Signed certificate**: `{domain}.crt`

## Notes

- If running on non-macOS systems, you'll need to manually add the root certificate to your trusted certificate store.
- Ensure that OpenSSL is installed and available in your PATH.
## License
This script is licensed under the [MIT License](LICENSE).
62 changes: 62 additions & 0 deletions packages/twenty-server/scripts/ssl-generation/script.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#!/bin/bash

# Check if OpenSSL is installed
if ! command -v openssl &> /dev/null
then
echo "OpenSSL is not installed. Please install it before running this script."
exit
fi

# Default values
DOMAIN=${1:-localhost.com}
ROOT_CERT_NAME=${2:-myRootCertificate}
VALIDITY_DAYS=${3:-825} # Default is 825 days

CERTS_DIR=~/certs/$DOMAIN

# Create a directory to store the certificates
mkdir -p $CERTS_DIR
cd $CERTS_DIR

# Generate the private key for the Certificate Authority (CA)
openssl genrsa -des3 -out ${ROOT_CERT_NAME}.key 2048

# Generate the root certificate for the CA
openssl req -x509 -new -nodes -key ${ROOT_CERT_NAME}.key -sha256 -days $VALIDITY_DAYS -out ${ROOT_CERT_NAME}.pem \
-subj "/C=US/ST=State/L=City/O=MyOrg/OU=MyUnit/CN=MyLocalCA"

# Add the root certificate to the macOS keychain (requires admin password)
if [[ "$OSTYPE" == "darwin"* ]]; then
sudo security add-trusted-cert -d -r trustRoot -k "/Library/Keychains/System.keychain" ${ROOT_CERT_NAME}.pem
fi

# Generate the private key for the provided domain
openssl genrsa -out $DOMAIN.key 2048

# Create a Certificate Signing Request (CSR) for the provided domain
openssl req -new -key $DOMAIN.key -out $DOMAIN.csr \
-subj "/C=US/ST=State/L=City/O=MyOrg/OU=MyUnit/CN=*.$DOMAIN"

# Create a configuration file for certificate extensions
cat > $DOMAIN.ext << EOF
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
subjectAltName = @alt_names
[alt_names]
DNS.1 = $DOMAIN
DNS.2 = *.$DOMAIN
EOF

# Sign the certificate with the CA
openssl x509 -req -in $DOMAIN.csr -CA ${ROOT_CERT_NAME}.pem -CAkey ${ROOT_CERT_NAME}.key -CAcreateserial \
-out $DOMAIN.crt -days $VALIDITY_DAYS -sha256 -extfile $DOMAIN.ext

echo "Certificates generated in the directory $CERTS_DIR:"
echo "- Root certificate: ${ROOT_CERT_NAME}.pem"
echo "- Domain private key: $DOMAIN.key"
echo "- Signed certificate: $DOMAIN.crt"

# Tips for usage
echo "To use these certificates with a local server, configure your server to use $DOMAIN.crt and $DOMAIN.key."
12 changes: 3 additions & 9 deletions packages/twenty-server/src/engine/api/rest/rest-api.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import { AxiosResponse } from 'axios';

import { Query } from 'src/engine/api/rest/core/types/query.type';
import { getServerUrl } from 'src/utils/get-server-url';
Copy link
Member

Choose a reason for hiding this comment

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

add TODO + Deprecate flag

import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { RestApiException } from 'src/engine/api/rest/errors/RestApiException';
import { ApiUrl } from 'src/engine/utils/server-and-api-urls';

export enum GraphqlApiType {
CORE = 'core',
Expand All @@ -16,16 +16,10 @@ export enum GraphqlApiType {

@Injectable()
export class RestApiService {
constructor(
private readonly environmentService: EnvironmentService,
private readonly httpService: HttpService,
) {}
constructor(private readonly httpService: HttpService) {}

async call(graphqlApiType: GraphqlApiType, request: Request, data: Query) {
const baseUrl = getServerUrl(
request,
this.environmentService.get('SERVER_URL'),
);
const baseUrl = getServerUrl(request, ApiUrl.get());
let response: AxiosResponse;
const url = `${baseUrl}/${
graphqlApiType === GraphqlApiType.CORE
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,13 +127,20 @@ export class EnvironmentVariables {
PG_SSL_ALLOW_SELF_SIGNED = false;

// Frontend URL
@IsUrl({ require_tld: false })
@IsUrl({ require_tld: false, require_protocol: true })
FRONT_BASE_URL: string;

// Server URL
@IsUrl({ require_tld: false })
// URL of the nodejs server
// use an SSL certificate to be compliant with security certifications
@IsUrl({ require_tld: false, require_protocol: true })
@IsOptional()
SERVER_URL: string;
SERVER_URL = 'http://localhost';

// URL of the API, differ from SERVER_URL if you use a proxy like a load balancer
@IsOptional()
@IsUrl({ require_tld: false, require_protocol: true })
API_URL: string;

@IsString()
APP_SECRET: string;
Expand Down Expand Up @@ -166,7 +173,7 @@ export class EnvironmentVariables {
INVITATION_TOKEN_EXPIRES_IN = '30d';

// Auth
@IsUrl({ require_tld: false })
@IsUrl({ require_tld: false, require_protocol: true })
@IsOptional()
FRONT_AUTH_CALLBACK_URL: string;

Expand Down Expand Up @@ -198,11 +205,11 @@ export class EnvironmentVariables {
@ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED)
AUTH_MICROSOFT_CLIENT_SECRET: string;

@IsUrl({ require_tld: false })
@IsUrl({ require_tld: false, require_protocol: true })
@ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED)
AUTH_MICROSOFT_CALLBACK_URL: string;

@IsUrl({ require_tld: false })
@IsUrl({ require_tld: false, require_protocol: true })
@ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED)
AUTH_MICROSOFT_APIS_CALLBACK_URL: string;

Expand All @@ -219,7 +226,7 @@ export class EnvironmentVariables {
@ValidateIf((env) => env.AUTH_GOOGLE_ENABLED)
AUTH_GOOGLE_CLIENT_SECRET: string;

@IsUrl({ require_tld: false })
@IsUrl({ require_tld: false, require_protocol: true })
@ValidateIf((env) => env.AUTH_GOOGLE_ENABLED)
AUTH_GOOGLE_CALLBACK_URL: string;

Expand Down Expand Up @@ -475,6 +482,15 @@ export class EnvironmentVariables {
// milliseconds
@CastToPositiveNumber()
SERVERLESS_FUNCTION_EXEC_THROTTLE_TTL = 1000;

// SSL
@IsString()
@ValidateIf((env) => env.SERVER_URL.startsWith('https'))
SSL_KEY_PATH: string;

@IsString()
@ValidateIf((env) => env.SERVER_URL.startsWith('https'))
SSL_CERT_PATH: string;
AMoreaux marked this conversation as resolved.
Show resolved Hide resolved
}

export const validate = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { Request } from 'express';
import { OpenAPIV3_1 } from 'openapi-types';

import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { baseSchema } from 'src/engine/core-modules/open-api/utils/base-schema.utils';
import {
computeMetadataSchemaComponents,
Expand Down Expand Up @@ -38,20 +37,17 @@ import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metada
import { capitalize } from 'src/utils/capitalize';
import { getServerUrl } from 'src/utils/get-server-url';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { ApiUrl } from 'src/engine/utils/server-and-api-urls';

@Injectable()
export class OpenApiService {
constructor(
private readonly accessTokenService: AccessTokenService,
private readonly environmentService: EnvironmentService,
private readonly objectMetadataService: ObjectMetadataService,
) {}

async generateCoreSchema(request: Request): Promise<OpenAPIV3_1.Document> {
const baseUrl = getServerUrl(
request,
this.environmentService.get('SERVER_URL'),
);
const baseUrl = getServerUrl(request, ApiUrl.get());

const schema = baseSchema('core', baseUrl);

Expand Down Expand Up @@ -121,10 +117,7 @@ export class OpenApiService {
async generateMetaDataSchema(
request: Request,
): Promise<OpenAPIV3_1.Document> {
const baseUrl = getServerUrl(
request,
this.environmentService.get('SERVER_URL'),
);
const baseUrl = getServerUrl(request, ApiUrl.get());

const schema = baseSchema('metadata', baseUrl);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,6 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Issuer } from 'openid-client';
import { Repository } from 'typeorm';

import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FindAvailableSSOIDPOutput } from 'src/engine/core-modules/sso/dtos/find-available-SSO-IDP.output';
Expand All @@ -28,6 +24,7 @@ import {
WorkspaceSSOIdentityProvider,
} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { ApiUrl } from 'src/engine/utils/server-and-api-urls';

@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
Expand All @@ -39,9 +36,6 @@ export class SSOService {
private readonly workspaceSSOIdentityProviderRepository: Repository<WorkspaceSSOIdentityProvider>,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
private readonly environmentService: EnvironmentService,
@InjectCacheStorage(CacheStorageNamespace.EngineWorkspace)
private readonly cacheStorageService: CacheStorageService,
) {}

private async isSSOEnabled(workspaceId: string) {
Expand Down Expand Up @@ -189,7 +183,7 @@ export class SSOService {
buildCallbackUrl(
identityProvider: Pick<WorkspaceSSOIdentityProvider, 'type'>,
) {
const callbackURL = new URL(this.environmentService.get('SERVER_URL'));
const callbackURL = new URL(ApiUrl.get());

callbackURL.pathname = `/auth/${identityProvider.type.toLowerCase()}/callback`;

Expand All @@ -199,7 +193,11 @@ export class SSOService {
buildIssuerURL(
identityProvider: Pick<WorkspaceSSOIdentityProvider, 'id' | 'type'>,
) {
return `${this.environmentService.get('SERVER_URL')}/auth/${identityProvider.type.toLowerCase()}/login/${identityProvider.id}`;
const authorizationUrl = new URL(ApiUrl.get());

authorizationUrl.pathname = `/auth/${identityProvider.type.toLowerCase()}/login/${identityProvider.id}`;

return authorizationUrl.toString();
}

private isOIDCIdentityProvider(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-works
import { User } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceInvitationException } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.exception';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { ApiUrl } from 'src/engine/utils/server-and-api-urls';

import { WorkspaceInvitationService } from './workspace-invitation.service';

Expand Down Expand Up @@ -70,6 +71,7 @@ describe('WorkspaceInvitationService', () => {
environmentService = module.get<EnvironmentService>(EnvironmentService);
emailService = module.get<EmailService>(EmailService);
onboardingService = module.get<OnboardingService>(OnboardingService);
ApiUrl.set('http://localhost:3000');
});

it('should be defined', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
WorkspaceInvitationExceptionCode,
} from 'src/engine/core-modules/workspace-invitation/workspace-invitation.exception';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { ApiUrl } from 'src/engine/utils/server-and-api-urls';

@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
Expand Down Expand Up @@ -234,7 +235,7 @@ export class WorkspaceInvitationService {
link: link.toString(),
workspace: { name: workspace.displayName, logo: workspace.logo },
sender: { email: sender.email, firstName: sender.firstName },
serverUrl: this.environmentService.get('SERVER_URL'),
serverUrl: ApiUrl.get(),
AMoreaux marked this conversation as resolved.
Show resolved Hide resolved
};

const emailTemplate = SendInviteLinkEmail(emailData);
Expand Down
Loading
Loading