diff --git a/packages/cli/src/PublicApi/index.ts b/packages/cli/src/PublicApi/index.ts index 8a88a51e073d8..bc3c76b9dedc2 100644 --- a/packages/cli/src/PublicApi/index.ts +++ b/packages/cli/src/PublicApi/index.ts @@ -11,11 +11,11 @@ import type { OpenAPIV3 } from 'openapi-types'; import type { JsonObject } from 'swagger-ui-express'; import config from '@/config'; -import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper'; import { Container } from 'typedi'; import { InternalHooks } from '@/InternalHooks'; import { License } from '@/License'; import { UserRepository } from '@db/repositories/user.repository'; +import { InstanceService } from '@/services/instance.service'; async function createApiRouter( version: string, @@ -29,7 +29,9 @@ async function createApiRouter( // from the Swagger UI swaggerDocument.server = [ { - url: `${getInstanceBaseUrl()}/${publicApiEndpoint}/${version}}`, + url: `${Container.get( + InstanceService, + ).getInstanceBaseUrl()}/${publicApiEndpoint}/${version}}`, }, ]; const apiController = express.Router(); diff --git a/packages/cli/src/UserManagement/UserManagementHelper.ts b/packages/cli/src/UserManagement/UserManagementHelper.ts index 50de670329cfa..03038215b5773 100644 --- a/packages/cli/src/UserManagement/UserManagementHelper.ts +++ b/packages/cli/src/UserManagement/UserManagementHelper.ts @@ -6,9 +6,7 @@ import * as ResponseHelper from '@/ResponseHelper'; import type { WhereClause } from '@/Interfaces'; import type { User } from '@db/entities/User'; import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH } from '@db/entities/User'; -import config from '@/config'; import { License } from '@/License'; -import { getWebhookBaseUrl } from '@/WebhookHelpers'; import { RoleService } from '@/services/role.service'; import { UserRepository } from '@db/repositories/user.repository'; @@ -27,19 +25,6 @@ export async function getInstanceOwner() { }); } -/** - * Return the n8n instance base URL without trailing slash. - */ -export function getInstanceBaseUrl(): string { - const n8nBaseUrl = config.getEnv('editorBaseUrl') || getWebhookBaseUrl(); - - return n8nBaseUrl.endsWith('/') ? n8nBaseUrl.slice(0, n8nBaseUrl.length - 1) : n8nBaseUrl; -} - -export function generateUserInviteUrl(inviterId: string, inviteeId: string): string { - return `${getInstanceBaseUrl()}/signup?inviterId=${inviterId}&inviteeId=${inviteeId}`; -} - // TODO: Enforce at model level export function validatePassword(password?: string): string { if (!password) { diff --git a/packages/cli/src/controllers/oauth/abstractOAuth.controller.ts b/packages/cli/src/controllers/oauth/abstractOAuth.controller.ts index babf90f4c6634..d5009cbfbe13a 100644 --- a/packages/cli/src/controllers/oauth/abstractOAuth.controller.ts +++ b/packages/cli/src/controllers/oauth/abstractOAuth.controller.ts @@ -7,7 +7,6 @@ import type { User } from '@db/entities/User'; import { CredentialsRepository } from '@db/repositories/credentials.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import type { ICredentialsDb } from '@/Interfaces'; -import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper'; import type { OAuthRequest } from '@/requests'; import { BadRequestError, NotFoundError } from '@/ResponseHelper'; import { RESPONSE_ERROR_MESSAGES } from '@/constants'; @@ -15,6 +14,7 @@ import { CredentialsHelper } from '@/CredentialsHelper'; import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; import { Logger } from '@/Logger'; import { ExternalHooks } from '@/ExternalHooks'; +import { InstanceService } from '@/services/instance.service'; @Service() export abstract class AbstractOAuthController { @@ -26,10 +26,13 @@ export abstract class AbstractOAuthController { private readonly credentialsHelper: CredentialsHelper, private readonly credentialsRepository: CredentialsRepository, private readonly sharedCredentialsRepository: SharedCredentialsRepository, + private readonly instanceService: InstanceService, ) {} get baseUrl() { - const restUrl = `${getInstanceBaseUrl()}/${config.getEnv('endpoints.rest')}`; + const restUrl = `${this.instanceService.getInstanceBaseUrl()}/${config.getEnv( + 'endpoints.rest', + )}`; return `${restUrl}/oauth${this.oauthVersion}-credential`; } diff --git a/packages/cli/src/controllers/passwordReset.controller.ts b/packages/cli/src/controllers/passwordReset.controller.ts index 6396e2d689f7a..9c17b6ed48216 100644 --- a/packages/cli/src/controllers/passwordReset.controller.ts +++ b/packages/cli/src/controllers/passwordReset.controller.ts @@ -12,11 +12,7 @@ import { UnauthorizedError, UnprocessableRequestError, } from '@/ResponseHelper'; -import { - getInstanceBaseUrl, - hashPassword, - validatePassword, -} from '@/UserManagement/UserManagementHelper'; +import { hashPassword, validatePassword } from '@/UserManagement/UserManagementHelper'; import { UserManagementMailer } from '@/UserManagement/email'; import { PasswordResetRequest } from '@/requests'; import { issueCookie } from '@/auth/jwt'; @@ -29,6 +25,7 @@ import { MfaService } from '@/Mfa/mfa.service'; import { Logger } from '@/Logger'; import { ExternalHooks } from '@/ExternalHooks'; import { InternalHooks } from '@/InternalHooks'; +import { InstanceService } from '@/services/instance.service'; const throttle = rateLimit({ windowMs: 5 * 60 * 1000, // 5 minutes @@ -47,6 +44,7 @@ export class PasswordResetController { private readonly userService: UserService, private readonly mfaService: MfaService, private readonly license: License, + private readonly instanceService: InstanceService, ) {} /** @@ -131,7 +129,7 @@ export class PasswordResetController { firstName, lastName, passwordResetUrl: url, - domain: getInstanceBaseUrl(), + domain: this.instanceService.getInstanceBaseUrl(), }); } catch (error) { void this.internalHooks.onEmailFailed({ diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 34ba3c1f0839c..c43ce47bb8e71 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -17,7 +17,6 @@ import { CredentialsOverwrites } from '@/CredentialsOverwrites'; import { CredentialTypes } from '@/CredentialTypes'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { License } from '@/License'; -import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper'; import * as WebhookHelpers from '@/WebhookHelpers'; import config from '@/config'; import { getCurrentAuthenticationMethod } from '@/sso/ssoHelpers'; @@ -31,6 +30,7 @@ import { import { UserManagementMailer } from '@/UserManagement/email'; import type { CommunityPackagesService } from '@/services/communityPackages.service'; import { Logger } from '@/Logger'; +import { InstanceService } from '@/services/instance.service'; @Service() export class FrontendService { @@ -46,6 +46,7 @@ export class FrontendService { private readonly license: License, private readonly mailer: UserManagementMailer, private readonly instanceSettings: InstanceSettings, + private readonly instanceService: InstanceService, ) { loadNodesAndCredentials.addPostProcessor(async () => this.generateTypes()); void this.generateTypes(); @@ -61,7 +62,7 @@ export class FrontendService { } private initSettings() { - const instanceBaseUrl = getInstanceBaseUrl(); + const instanceBaseUrl = this.instanceService.getInstanceBaseUrl(); const restEndpoint = config.getEnv('endpoints.rest'); const telemetrySettings: ITelemetrySettings = { @@ -218,7 +219,7 @@ export class FrontendService { const restEndpoint = config.getEnv('endpoints.rest'); // Update all urls, in case `WEBHOOK_URL` was updated by `--tunnel` - const instanceBaseUrl = getInstanceBaseUrl(); + const instanceBaseUrl = this.instanceService.getInstanceBaseUrl(); this.settings.urlBaseWebhook = WebhookHelpers.getWebhookBaseUrl(); this.settings.urlBaseEditor = instanceBaseUrl; this.settings.oauthCallbackUrls = { diff --git a/packages/cli/src/services/instance.service.ts b/packages/cli/src/services/instance.service.ts new file mode 100644 index 0000000000000..1bfeef1bb33c6 --- /dev/null +++ b/packages/cli/src/services/instance.service.ts @@ -0,0 +1,36 @@ +import { Service } from 'typedi'; +import config from '@/config'; + +@Service() +export class InstanceService { + /** + * Return the n8n instance base URL without trailing slash. + */ + getInstanceBaseUrl(): string { + const n8nBaseUrl = config.getEnv('editorBaseUrl') || this.getWebhookBaseUrl(); + return n8nBaseUrl.endsWith('/') ? n8nBaseUrl.slice(0, n8nBaseUrl.length - 1) : n8nBaseUrl; + } + + getWebhookBaseUrl() { + let urlBaseWebhook = process.env.WEBHOOK_URL ?? this.getBaseUrl(); + if (!urlBaseWebhook.endsWith('/')) { + urlBaseWebhook += '/'; + } + return urlBaseWebhook; + } + + /** + * Returns the base URL n8n is reachable from + */ + private getBaseUrl() { + const protocol = config.getEnv('protocol'); + const host = config.getEnv('host'); + const port = config.getEnv('port'); + const path = config.getEnv('path'); + + if ((protocol === 'http' && port === 80) || (protocol === 'https' && port === 443)) { + return `${protocol}://${host}${path}`; + } + return `${protocol}://${host}:${port}${path}`; + } +} diff --git a/packages/cli/src/services/user.service.ts b/packages/cli/src/services/user.service.ts index 3b3a649731b67..400ee3ad7abee 100644 --- a/packages/cli/src/services/user.service.ts +++ b/packages/cli/src/services/user.service.ts @@ -4,7 +4,6 @@ import { In } from 'typeorm'; import { User } from '@db/entities/User'; import type { IUserSettings } from 'n8n-workflow'; import { UserRepository } from '@db/repositories/user.repository'; -import { generateUserInviteUrl, getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper'; import type { PublicUser } from '@/Interfaces'; import type { PostHogClient } from '@/posthog'; import { type JwtPayload, JwtService } from './jwt.service'; @@ -17,6 +16,7 @@ import { RoleService } from '@/services/role.service'; import { ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; import { InternalServerError } from '@/ResponseHelper'; import type { UserRequest } from '@/requests'; +import { InstanceService } from '@/services/instance.service'; @Service() export class UserService { @@ -26,6 +26,7 @@ export class UserService { private readonly jwtService: JwtService, private readonly mailer: UserManagementMailer, private readonly roleService: RoleService, + private readonly instanceService: InstanceService, ) {} async findOne(options: FindOneOptions) { @@ -78,7 +79,7 @@ export class UserService { } generatePasswordResetUrl(user: User) { - const instanceBaseUrl = getInstanceBaseUrl(); + const instanceBaseUrl = this.instanceService.getInstanceBaseUrl(); const url = new URL(`${instanceBaseUrl}/change-password`); url.searchParams.append('token', this.generatePasswordResetToken(user)); @@ -151,7 +152,7 @@ export class UserService { } private addInviteUrl(user: PublicUser, inviterId: string) { - const url = new URL(getInstanceBaseUrl()); + const url = new URL(this.instanceService.getInstanceBaseUrl()); url.pathname = '/signup'; url.searchParams.set('inviterId', inviterId); url.searchParams.set('inviteeId', user.id); @@ -179,11 +180,11 @@ export class UserService { } private async sendEmails(owner: User, toInviteUsers: { [key: string]: string }) { - const domain = getInstanceBaseUrl(); + const domain = this.instanceService.getInstanceBaseUrl(); return Promise.all( Object.entries(toInviteUsers).map(async ([email, id]) => { - const inviteAcceptUrl = generateUserInviteUrl(owner.id, id); + const inviteAcceptUrl = this.generateUserInviteUrl(owner.id, id); const invitedUser: UserRequest.InviteResponse = { user: { id, @@ -287,4 +288,8 @@ export class UserService { return { usersInvited, usersCreated: toCreateUsers }; } + + private generateUserInviteUrl(inviterId: string, inviteeId: string): string { + return `${this.instanceService.getInstanceBaseUrl()}/signup?inviterId=${inviterId}&inviteeId=${inviteeId}`; + } } diff --git a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts index 67d441a37c7a1..11ef9941e0bff 100644 --- a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts +++ b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts @@ -1,6 +1,5 @@ import express from 'express'; import { Container, Service } from 'typedi'; -import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper'; import { Authorized, Get, NoAuthRequired, Post, RestController } from '@/decorators'; import { SamlUrls } from '../constants'; import { @@ -27,11 +26,15 @@ import { getSamlConnectionTestFailedView } from '../views/samlConnectionTestFail import { InternalHooks } from '@/InternalHooks'; import url from 'url'; import querystring from 'querystring'; +import { InstanceService } from '@/services/instance.service'; @Service() @RestController('/sso/saml') export class SamlController { - constructor(private samlService: SamlService) {} + constructor( + private samlService: SamlService, + private instanceService: InstanceService, + ) {} @NoAuthRequired() @Get(SamlUrls.metadata) @@ -51,8 +54,8 @@ export class SamlController { const prefs = this.samlService.samlPreferences; return { ...prefs, - entityID: getServiceProviderEntityId(), - returnUrl: getServiceProviderReturnUrl(), + entityID: getServiceProviderEntityId(this.instanceService.getInstanceBaseUrl()), + returnUrl: getServiceProviderReturnUrl(this.instanceService.getInstanceBaseUrl()), }; } @@ -119,6 +122,8 @@ export class SamlController { res: express.Response, binding: SamlLoginBinding, ) { + const instanceBaseUrl = this.instanceService.getInstanceBaseUrl(); + try { const loginResult = await this.samlService.handleSamlLogin(req, binding); // if RelayState is set to the test connection Url, this is a test connection @@ -138,10 +143,10 @@ export class SamlController { if (isSamlLicensedAndEnabled()) { await issueCookie(res, loginResult.authenticatedUser); if (loginResult.onboardingRequired) { - return res.redirect(getInstanceBaseUrl() + SamlUrls.samlOnboarding); + return res.redirect(instanceBaseUrl + SamlUrls.samlOnboarding); } else { const redirectUrl = req.body?.RelayState ?? SamlUrls.defaultRedirect; - return res.redirect(getInstanceBaseUrl() + redirectUrl); + return res.redirect(instanceBaseUrl + redirectUrl); } } else { return res.status(202).send(loginResult.attributes); @@ -198,7 +203,10 @@ export class SamlController { @Authorized(['global', 'owner']) @Get(SamlUrls.configTest, { middlewares: [samlLicensedMiddleware] }) async configTestGet(req: AuthenticatedRequest, res: express.Response) { - return this.handleInitSSO(res, getServiceProviderConfigTestReturnUrl()); + return this.handleInitSSO( + res, + getServiceProviderConfigTestReturnUrl(this.instanceService.getInstanceBaseUrl()), + ); } private async handleInitSSO(res: express.Response, relayState?: string) { diff --git a/packages/cli/src/sso/saml/saml.service.ee.ts b/packages/cli/src/sso/saml/saml.service.ee.ts index 354686cbb3b2c..0fe1cf43d0a1a 100644 --- a/packages/cli/src/sso/saml/saml.service.ee.ts +++ b/packages/cli/src/sso/saml/saml.service.ee.ts @@ -25,10 +25,10 @@ import axios from 'axios'; import https from 'https'; import type { SamlLoginBinding } from './types'; import { validateMetadata, validateResponse } from './samlValidator'; -import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper'; import { Logger } from '@/Logger'; import { UserRepository } from '@db/repositories/user.repository'; import { SettingsRepository } from '@db/repositories/settings.repository'; +import { InstanceService } from '@/services/instance.service'; @Service() export class SamlService { @@ -54,7 +54,7 @@ export class SamlService { loginLabel: 'SAML', wantAssertionsSigned: true, wantMessageSigned: true, - relayState: getInstanceBaseUrl(), + relayState: undefined, signatureConfig: { prefix: 'ds', location: { @@ -72,7 +72,10 @@ export class SamlService { }; } - constructor(private readonly logger: Logger) {} + constructor( + private readonly logger: Logger, + private readonly instanceService: InstanceService, + ) {} async init(): Promise { // load preferences first but do not apply so as to not load samlify unnecessarily @@ -142,14 +145,14 @@ export class SamlService { private getRedirectLoginRequestUrl(relayState?: string): BindingContext { const sp = this.getServiceProviderInstance(); - sp.entitySetting.relayState = relayState ?? getInstanceBaseUrl(); + sp.entitySetting.relayState = relayState ?? this.instanceService.getInstanceBaseUrl(); const loginRequest = sp.createLoginRequest(this.getIdentityProviderInstance(), 'redirect'); return loginRequest; } private getPostLoginRequestUrl(relayState?: string): PostBindingContext { const sp = this.getServiceProviderInstance(); - sp.entitySetting.relayState = relayState ?? getInstanceBaseUrl(); + sp.entitySetting.relayState = relayState ?? this.instanceService.getInstanceBaseUrl(); const loginRequest = sp.createLoginRequest( this.getIdentityProviderInstance(), 'post', diff --git a/packages/cli/src/sso/saml/samlHelpers.ts b/packages/cli/src/sso/saml/samlHelpers.ts index d55b751be73bf..76e3ebc1e5e85 100644 --- a/packages/cli/src/sso/saml/samlHelpers.ts +++ b/packages/cli/src/sso/saml/samlHelpers.ts @@ -21,6 +21,7 @@ import type { SamlConfiguration } from './types/requests'; import { RoleService } from '@/services/role.service'; import { UserRepository } from '@db/repositories/user.repository'; import { AuthIdentityRepository } from '@db/repositories/authIdentity.repository'; +import { InstanceService } from '@/services/instance.service'; /** * Check whether the SAML feature is licensed and enabled in the instance */ @@ -178,5 +179,8 @@ export function getMappedSamlAttributesFromFlowResult( } export function isConnectionTestRequest(req: SamlConfiguration.AcsRequest): boolean { - return req.body.RelayState === getServiceProviderConfigTestReturnUrl(); + return ( + req.body.RelayState === + getServiceProviderConfigTestReturnUrl(Container.get(InstanceService).getInstanceBaseUrl()) + ); } diff --git a/packages/cli/src/sso/saml/serviceProvider.ee.ts b/packages/cli/src/sso/saml/serviceProvider.ee.ts index fa1fde62832d7..6ab29cd5cc648 100644 --- a/packages/cli/src/sso/saml/serviceProvider.ee.ts +++ b/packages/cli/src/sso/saml/serviceProvider.ee.ts @@ -1,21 +1,22 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper'; import type { ServiceProviderInstance } from 'samlify'; import { SamlUrls } from './constants'; import type { SamlPreferences } from './types/samlPreferences'; +import { InstanceService } from '@/services/instance.service'; +import Container from 'typedi'; let serviceProviderInstance: ServiceProviderInstance | undefined; -export function getServiceProviderEntityId(): string { - return getInstanceBaseUrl() + SamlUrls.restMetadata; +export function getServiceProviderEntityId(baseUrl: string): string { + return baseUrl + SamlUrls.restMetadata; } -export function getServiceProviderReturnUrl(): string { - return getInstanceBaseUrl() + SamlUrls.restAcs; +export function getServiceProviderReturnUrl(baseUrl: string): string { + return baseUrl + SamlUrls.restAcs; } -export function getServiceProviderConfigTestReturnUrl(): string { - return getInstanceBaseUrl() + SamlUrls.configTestReturn; +export function getServiceProviderConfigTestReturnUrl(baseUrl: string): string { + return baseUrl + SamlUrls.configTestReturn; } // TODO:SAML: make these configurable for the end user @@ -24,9 +25,11 @@ export function getServiceProviderInstance( // eslint-disable-next-line @typescript-eslint/consistent-type-imports samlify: typeof import('samlify'), ): ServiceProviderInstance { + const baseUrl = Container.get(InstanceService).getInstanceBaseUrl(); + if (serviceProviderInstance === undefined) { serviceProviderInstance = samlify.ServiceProvider({ - entityID: getServiceProviderEntityId(), + entityID: getServiceProviderEntityId(baseUrl), authnRequestsSigned: prefs.authnRequestsSigned, wantAssertionsSigned: prefs.wantAssertionsSigned, wantMessageSigned: prefs.wantMessageSigned, @@ -37,12 +40,12 @@ export function getServiceProviderInstance( { isDefault: prefs.acsBinding === 'post', Binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', - Location: getServiceProviderReturnUrl(), + Location: getServiceProviderReturnUrl(baseUrl), }, { isDefault: prefs.acsBinding === 'redirect', Binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-REDIRECT', - Location: getServiceProviderReturnUrl(), + Location: getServiceProviderReturnUrl(baseUrl), }, ], });