diff --git a/apps/server/src/core/integration/oauth/oauth.controller.ts b/apps/server/src/core/integration/oauth/oauth.controller.ts index 86e29d2f1..30ba86a07 100644 --- a/apps/server/src/core/integration/oauth/oauth.controller.ts +++ b/apps/server/src/core/integration/oauth/oauth.controller.ts @@ -65,6 +65,14 @@ export class OAuthController { throw new BadRequestException('Invalid or expired OAuth state'); } + // returnUrl is derived server-side at authorize time from the workspace's + // own hostname/customDomain (canonical DB truth, not user input), then + // signed into the state JWT. Safe to use directly here — tampering would + // invalidate the signature; older tokens predating this field will be + // undefined and fall back to APP_URL. + const returnUrl = + statePayload.returnUrl || this.environmentService.getAppUrl(); + try { await this.oauthService.exchangeCodeForTokens( type, @@ -74,12 +82,12 @@ export class OAuthController { statePayload.workspaceId, ); - const appUrl = this.environmentService.getAppUrl(); - return res.redirect(`${appUrl}/settings/integrations`, 302).send(); + return res.redirect(`${returnUrl}/settings/integrations`, 302).send(); } catch (err) { this.logger.error(`OAuth callback error for ${type}: ${(err as Error).message}`); - const appUrl = this.environmentService.getAppUrl(); - return res.redirect(`${appUrl}/settings/integrations?error=oauth_failed`, 302).send(); + return res + .redirect(`${returnUrl}/settings/integrations?error=oauth_failed`, 302) + .send(); } } diff --git a/apps/server/src/core/integration/oauth/oauth.service.ts b/apps/server/src/core/integration/oauth/oauth.service.ts index 43e9f2542..c8795479c 100644 --- a/apps/server/src/core/integration/oauth/oauth.service.ts +++ b/apps/server/src/core/integration/oauth/oauth.service.ts @@ -5,9 +5,11 @@ import { NotFoundException, } from '@nestjs/common'; import { EnvironmentService } from '../../../integrations/environment/environment.service'; +import { DomainService } from '../../../integrations/environment/domain.service'; import { IntegrationRegistry } from '../registry/integration-registry'; import { IntegrationRepo } from '../repos/integration.repo'; import { IntegrationConnectionRepo } from '../repos/integration-connection.repo'; +import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; import { encryptToken, decryptToken } from '../crypto/token-crypto'; import { IntegrationConnection } from '@docmost/db/types/entity.types'; import { OAuthConfig } from '../registry/integration-provider.interface'; @@ -25,6 +27,11 @@ export type OAuthStatePayload = { integrationId: string; userId: string; workspaceId: string; + // Workspace's canonical URL at authorize time. Cloud workspaces are routed + // through a single central OAuth callback (the only redirect_uri Slack/etc. + // accept), and this lets the callback redirect the user back to their own + // workspace host (subdomain or custom domain) after token exchange. + returnUrl: string; exp: number; }; @@ -34,9 +41,11 @@ export class OAuthService { constructor( private readonly environmentService: EnvironmentService, + private readonly domainService: DomainService, private readonly registry: IntegrationRegistry, private readonly integrationRepo: IntegrationRepo, private readonly connectionRepo: IntegrationConnectionRepo, + private readonly workspaceRepo: WorkspaceRepo, ) {} async getAuthorizationUrl( @@ -60,10 +69,16 @@ export class OAuthService { const callbackUrl = this.buildCallbackUrl(integration.type); + const workspace = await this.workspaceRepo.findById(workspaceId); + const returnUrl = this.domainService.getWorkspaceUrl( + workspace ?? { hostname: null, customDomain: null }, + ); + const state = this.createSignedState({ integrationId, userId, workspaceId, + returnUrl, exp: Date.now() + 10 * 60 * 1000, }); diff --git a/apps/server/src/integrations/environment/domain.service.ts b/apps/server/src/integrations/environment/domain.service.ts index fa6b17784..f00a2f515 100644 --- a/apps/server/src/integrations/environment/domain.service.ts +++ b/apps/server/src/integrations/environment/domain.service.ts @@ -18,4 +18,20 @@ export class DomainService { const protocol = this.environmentService.isHttps() ? 'https' : 'http'; return `${protocol}://${hostname}.${domain}`; } + + // Canonical workspace URL: prefers customDomain, falls back to {hostname}.{cloud-domain}, + // falls back to APP_URL for self-hosted. Used for multi-tenant OAuth return-redirects. + getWorkspaceUrl(workspace: { + hostname?: string | null; + customDomain?: string | null; + }): string { + if (!this.environmentService.isCloud()) { + return this.environmentService.getAppUrl(); + } + if (workspace.customDomain) { + const protocol = this.environmentService.isHttps() ? 'https' : 'http'; + return `${protocol}://${workspace.customDomain}`; + } + return this.getUrl(workspace.hostname ?? undefined); + } }