mirror of
https://github.com/docmost/docmost.git
synced 2026-08-22 10:22:12 +10:00
feat(integrations): defer workspace-scoped install until OAuth succeeds
This commit is contained in:
@@ -49,3 +49,9 @@ export class OAuthDisconnectDto {
|
||||
@IsString()
|
||||
integrationId: string;
|
||||
}
|
||||
|
||||
export class OAuthInstallDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
type: string;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,11 @@ import { AuthUser } from '../../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { OAuthService } from './oauth.service';
|
||||
import { OAuthAuthorizeDto, OAuthDisconnectDto } from '../dto/integration.dto';
|
||||
import {
|
||||
OAuthAuthorizeDto,
|
||||
OAuthDisconnectDto,
|
||||
OAuthInstallDto,
|
||||
} from '../dto/integration.dto';
|
||||
import { IntegrationConnectionService } from '../integration-connection.service';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
|
||||
@@ -49,6 +53,29 @@ export class OAuthController {
|
||||
return { authorizationUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* Install-and-authorize for workspace-scoped providers (Slack model).
|
||||
* Returns the authorize URL without first creating the integration row;
|
||||
* the row is created atomically on successful OAuth callback so a cancelled
|
||||
* OAuth flow leaves no half-installed state.
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('install')
|
||||
async installAndAuthorize(
|
||||
@Body() dto: OAuthInstallDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const { authorizationUrl } = await this.oauthService.getInstallAuthorizationUrl(
|
||||
dto.type,
|
||||
workspace.id,
|
||||
user.id,
|
||||
);
|
||||
|
||||
return { authorizationUrl };
|
||||
}
|
||||
|
||||
@Get(':type/callback')
|
||||
async callback(
|
||||
@Param('type') type: string,
|
||||
|
||||
@@ -24,7 +24,12 @@ type OAuthTokenResponse = {
|
||||
};
|
||||
|
||||
export type OAuthStatePayload = {
|
||||
integrationId: string;
|
||||
// For "authorize-only" flows (per-user OAuth on an already-installed
|
||||
// integration) integrationId is set; for "install-and-authorize" flows
|
||||
// (workspace-scoped providers like Slack) it's null until the callback
|
||||
// resolves-or-creates the row atomically with token exchange success.
|
||||
integrationId: string | null;
|
||||
type: string;
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
// Workspace's canonical URL at authorize time. Cloud workspaces are routed
|
||||
@@ -76,6 +81,7 @@ export class OAuthService {
|
||||
|
||||
const state = this.createSignedState({
|
||||
integrationId,
|
||||
type: integration.type,
|
||||
userId,
|
||||
workspaceId,
|
||||
returnUrl,
|
||||
@@ -98,6 +104,75 @@ export class OAuthService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Install-and-authorize for workspace-scoped providers (Slack model).
|
||||
*
|
||||
* Skips creating the integration row up front. The callback (atomic with
|
||||
* token exchange success) is what actually persists the integration; if the
|
||||
* user cancels at Slack's consent screen, nothing is written. Refusing the
|
||||
* already-installed case here keeps the install button idempotent.
|
||||
*/
|
||||
async getInstallAuthorizationUrl(
|
||||
type: string,
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
): Promise<{ authorizationUrl: string }> {
|
||||
const provider = this.registry.getProvider(type);
|
||||
if (!provider || !provider.definition.oauth) {
|
||||
throw new BadRequestException('Integration does not support OAuth');
|
||||
}
|
||||
if (provider.definition.oauth.connectionScope !== 'workspace') {
|
||||
throw new BadRequestException(
|
||||
'This integration uses per-user OAuth; use the standard install + authorize flow',
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.integrationRepo.findByWorkspaceAndType(
|
||||
workspaceId,
|
||||
type,
|
||||
);
|
||||
if (existing) {
|
||||
throw new BadRequestException(
|
||||
`Integration "${type}" is already installed`,
|
||||
);
|
||||
}
|
||||
|
||||
const oauthConfig = provider.getOAuthConfig
|
||||
? provider.getOAuthConfig({})
|
||||
: provider.definition.oauth;
|
||||
|
||||
const callbackUrl = this.buildCallbackUrl(type);
|
||||
|
||||
const workspace = await this.workspaceRepo.findById(workspaceId);
|
||||
const returnUrl = this.domainService.getWorkspaceUrl(
|
||||
workspace ?? { hostname: null, customDomain: null },
|
||||
);
|
||||
|
||||
const state = this.createSignedState({
|
||||
integrationId: null,
|
||||
type,
|
||||
userId,
|
||||
workspaceId,
|
||||
returnUrl,
|
||||
exp: Date.now() + 10 * 60 * 1000,
|
||||
});
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: this.getClientId(type),
|
||||
redirect_uri: callbackUrl,
|
||||
response_type: 'code',
|
||||
state,
|
||||
});
|
||||
|
||||
const scope = oauthConfig.scopes
|
||||
.map((s) => encodeURIComponent(s))
|
||||
.join('%20');
|
||||
|
||||
return {
|
||||
authorizationUrl: `${oauthConfig.authUrl}?${params.toString()}&scope=${scope}`,
|
||||
};
|
||||
}
|
||||
|
||||
verifySignedState(state: string): OAuthStatePayload | null {
|
||||
const dotIndex = state.lastIndexOf('.');
|
||||
if (dotIndex === -1) return null;
|
||||
@@ -129,7 +204,7 @@ export class OAuthService {
|
||||
async exchangeCodeForTokens(
|
||||
type: string,
|
||||
code: string,
|
||||
integrationId: string,
|
||||
integrationId: string | null,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<IntegrationConnection> {
|
||||
@@ -138,8 +213,22 @@ export class OAuthService {
|
||||
throw new BadRequestException('Integration does not support OAuth');
|
||||
}
|
||||
|
||||
const integration = await this.integrationRepo.findById(integrationId);
|
||||
const settings = (integration?.settings as Record<string, any>) ?? {};
|
||||
// Install-and-authorize flow (workspace-scoped providers): no integration
|
||||
// row exists yet. Create or restore it now that OAuth has succeeded.
|
||||
let integration = integrationId
|
||||
? await this.integrationRepo.findById(integrationId)
|
||||
: null;
|
||||
|
||||
if (!integration) {
|
||||
integration = await this.integrationRepo.insertOrRestore({
|
||||
type,
|
||||
workspaceId,
|
||||
installedById: userId,
|
||||
});
|
||||
integrationId = integration.id;
|
||||
}
|
||||
|
||||
const settings = (integration.settings as Record<string, any>) ?? {};
|
||||
|
||||
const oauthConfig = provider.getOAuthConfig
|
||||
? provider.getOAuthConfig(settings)
|
||||
|
||||
Reference in New Issue
Block a user