feat(integrations): route OAuth callbacks back to originating workspace host

This commit is contained in:
Philipinho
2026-05-23 12:32:18 +01:00
parent 2f313187ab
commit 91a2abd8d3
3 changed files with 43 additions and 4 deletions
@@ -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();
}
}
@@ -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,
});
@@ -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);
}
}