n8n/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.controller.ts

353 lines
13 KiB
TypeScript

import { EventService, UrlService, CredentialsFinderService } from '@n8n/backend-services';
import { Time } from '@n8n/constants';
import { CredentialsEntity, AuthenticatedRequest, isAuthenticatedRequest } from '@n8n/db';
import { Delete, Get, Options, Param, Post, RestController } from '@n8n/decorators';
import { Container } from '@n8n/di';
import { ensureError } from '@n8n/utils/errors/ensure-error';
import { Request, Response } from 'express';
import { Cipher } from 'n8n-core';
import { type ICredentialContext, jsonParse } from 'n8n-workflow';
import { EnterpriseCredentialsService } from '@/credentials/credentials.service.ee';
import { BadRequestError, NotFoundError } from '@n8n/errors';
import { CreateCsrfStateData, OauthService } from '@/oauth/oauth.service';
import { carriesN8nIdentity } from './credential-resolvers/identifiers/n8n-identifier';
import { DynamicCredentialResolverRepository } from './database/repositories/credential-resolver.repository';
import { DynamicCredentialsConfig } from './dynamic-credentials.config';
import { N8nIdentityNotSupportedError } from './errors/n8n-identity-not-supported.error';
import {
AuthorizeIntentService,
CredentialConnectionStatusService,
DynamicCredentialResolverRegistry,
DynamicCredentialService,
} from './services';
import { DynamicCredentialCorsService } from './services/dynamic-credential-cors.service';
import { DynamicCredentialWebService } from './services/dynamic-credential-web.service';
import { getDynamicCredentialMiddlewares } from './utils';
const dynamicCredentialsConfig = Container.get(DynamicCredentialsConfig);
const NO_CONNECTION_TO_DISCONNECT = 'No connection to disconnect';
@RestController('/credentials')
export class DynamicCredentialsController {
constructor(
private readonly enterpriseCredentialsService: EnterpriseCredentialsService,
private readonly oauthService: OauthService,
private readonly resolverRepository: DynamicCredentialResolverRepository,
private readonly resolverRegistry: DynamicCredentialResolverRegistry,
private readonly cipher: Cipher,
private readonly dynamicCredentialCorsService: DynamicCredentialCorsService,
private readonly dynamicCredentialWebService: DynamicCredentialWebService,
private readonly credentialsFinderService: CredentialsFinderService,
private readonly credentialConnectionStatusService: CredentialConnectionStatusService,
private readonly eventService: EventService,
private readonly authorizeIntentService: AuthorizeIntentService,
private readonly dynamicCredentialService: DynamicCredentialService,
private readonly urlService: UrlService,
) {}
private async findCredentialToUse(credentialId: string): Promise<CredentialsEntity> {
// No project scope is checked: the connect half of this flow never had one, and
// the resolver keys every read and write on the caller's own identity, so a
// caller can only reach their own stored token.
const credential = await this.enterpriseCredentialsService.getOne(credentialId);
// These routes serve only end-user credentials, whose token is per-caller. A
// fixed credential's token lives on the shared row, so changing it is an edit
// and stays on the `credential:update` paths. Folded into the not-found branch
// so the response cannot tell "fixed" from "no such credential".
if (!credential?.isResolvable) {
throw new NotFoundError('Credential not found');
}
if (
!credential.type.toLowerCase().includes('oauth2') &&
!credential.type.toLowerCase().includes('oauth1')
) {
throw new BadRequestError('Credential type not supported');
}
return credential;
}
private async getResolverInstance(
resolverId: string | undefined,
credentialContext: ICredentialContext,
credentialName: string,
) {
if (!resolverId) {
throw new BadRequestError('Missing resolverId query parameter');
}
const resolverEntity = await this.resolverRepository.findOneBy({
id: resolverId,
});
if (!resolverEntity) {
throw new NotFoundError('Resolver not found');
}
// Get resolver instance from registry
const resolver = this.resolverRegistry.getResolverByTypename(resolverEntity.type);
if (!resolver) {
throw new NotFoundError('Resolver type not found');
}
// The caller names the resolver, and clients take that id from the workflow's
// effective resolver rather than the credential's own, so it can be any
// registered resolver. Refuse to hand an n8n session token to a resolver that
// keys on an external subject (it would forward the token to a third party).
if (carriesN8nIdentity(credentialContext) && !resolver.resolveOwningUserId) {
throw new BadRequestError(new N8nIdentityNotSupportedError(credentialName).message);
}
return { resolver, resolverEntity };
}
/**
* OPTIONS /credentials/:id/revoke
*
* Handles CORS preflight requests
*/
@Options('/:id/revoke', { skipAuth: true })
handlePreflightCredentialRevoke(req: Request, res: Response): void {
this.dynamicCredentialCorsService.preflightHandler(req, res, ['delete', 'options']);
}
@Delete('/:id/revoke', {
allowUnauthenticated: true,
middlewares: getDynamicCredentialMiddlewares(),
ipRateLimit: {
limit: dynamicCredentialsConfig.rateLimitPerMinute,
windowMs: 1 * Time.minutes.toMilliseconds,
},
})
async revokeCredential(req: Request, res: Response): Promise<void> {
this.dynamicCredentialCorsService.applyCorsHeadersIfEnabled(req, res, ['delete', 'options']);
const credentialContext = this.dynamicCredentialWebService.getCredentialContextFromRequest(req);
const credential = await this.findCredentialToUse(req.params.id);
const resolverId = req.query.resolverId as string | undefined;
const { resolver, resolverEntity } = await this.getResolverInstance(
resolverId,
credentialContext,
credential.name,
);
if (resolver.deleteSecret) {
// Decrypt and parse resolver configuration
const decryptedConfig = await this.cipher.decryptV2(resolverEntity.config);
const resolverConfig = jsonParse<Record<string, unknown>>(decryptedConfig);
await resolver.deleteSecret(credential.id, credentialContext, {
configuration: resolverConfig,
resolverId: resolverEntity.id,
resolverName: resolverEntity.type,
});
}
res.status(204).send(); // 204 No Content indicates successful deletion
}
/**
* OPTIONS /credentials/:id/authorize
*
* Handles CORS preflight requests
*/
@Options('/:id/authorize', { skipAuth: true })
handlePreflightCredentialAuthorize(req: Request, res: Response): void {
this.dynamicCredentialCorsService.preflightHandler(req, res, ['post', 'options']);
}
@Post('/:id/authorize', {
allowUnauthenticated: true,
middlewares: getDynamicCredentialMiddlewares(),
ipRateLimit: {
limit: dynamicCredentialsConfig.rateLimitAuthorizePerMinute,
windowMs: 1 * Time.minutes.toMilliseconds,
},
})
async authorizeCredential(req: Request, res: Response): Promise<string> {
this.dynamicCredentialCorsService.applyCorsHeadersIfEnabled(req, res, ['post', 'options']);
const credentialContext = this.dynamicCredentialWebService.getCredentialContextFromRequest(req);
const credential = await this.findCredentialToUse(req.params.id);
const resolverId = req.query.resolverId as string | undefined;
const { resolver, resolverEntity } = await this.getResolverInstance(
resolverId,
credentialContext,
credential.name,
);
if (resolver.validateIdentity) {
// Decrypt and parse resolver configuration
const decryptedConfig = await this.cipher.decryptV2(resolverEntity.config);
const resolverConfig = jsonParse<Record<string, unknown>>(decryptedConfig);
await resolver.validateIdentity(credentialContext, {
resolverId: resolverEntity.id,
resolverName: resolverEntity.type,
configuration: resolverConfig,
});
}
// Best-effort: bind the callback to the intended n8n user when the resolver
// names one, so `decodeCsrfState` rejects a mismatched session. External
// machine callers legitimately have no n8n user, so this never fails the POST.
const ownership = await this.dynamicCredentialService.resolveOwningUserIdForAuthorization(
credentialContext,
resolverEntity.id,
);
const csrfData: CreateCsrfStateData = {
cid: credential.id,
origin: 'dynamic-credential',
authorizationHeader: req.headers.authorization ?? `Bearer ${credentialContext.identity}`,
authMetadata: credentialContext.metadata,
credentialResolverId: req.query.resolverId,
userId: ownership.status === 'bound' ? ownership.userId : undefined,
};
if (credential.type.toLowerCase().includes('oauth2')) {
return await this.oauthService.generateAOauth2AuthUri(credential, csrfData, req, res);
}
if (credential.type.toLowerCase().includes('oauth1')) {
return await this.oauthService.generateAOauth1AuthUri(credential, csrfData, req, res);
}
throw new BadRequestError('Credential type not supported');
}
/**
* GET /credentials/:id/authorize?token=...
*
* Browser-clickable counterpart to the POST authorize flow. A credential gate hands
* back this short link instead of a large provider authorization URL; opening it
* materializes the OAuth flow (deferred discovery / client registration happens here)
* and redirects to the provider. The unguessable, short-lived `token` is the
* authorization — like the OAuth callback, no session or endpoint auth token is
* required, since the caller identity was captured server-side when the link was
* issued.
*/
@Get('/:id/authorize', {
allowUnauthenticated: true,
usesTemplates: true,
ipRateLimit: {
limit: dynamicCredentialsConfig.rateLimitAuthorizePerMinute,
windowMs: 1 * Time.minutes.toMilliseconds,
},
})
async authorizeCredentialRedirect(req: Request, res: Response): Promise<void> {
const token = typeof req.query.token === 'string' ? req.query.token : undefined;
if (!token) {
this.oauthService.renderCallbackError(res, 'Missing authorization token.');
return;
}
const intent = await this.authorizeIntentService.get(token);
if (!intent || intent.credentialId !== req.params.id) {
this.oauthService.renderCallbackError(
res,
'This authorization link is invalid or has expired. Please request a new one.',
);
return;
}
// When the link is bound to an n8n user, the clicker must be that user.
if (intent.userId) {
const user = isAuthenticatedRequest(req) ? req.user : undefined;
if (!user) {
this.eventService.emit('dynamic-credential-authorize-rejected', {
reason: 'unauthenticated',
credentialId: intent.credentialId,
});
// Absolute, same-origin http(s) URL so SigninView returns here after login.
const returnUrl = `${this.urlService.getInstanceBaseUrl()}${req.originalUrl}`;
res.redirect(
`${this.urlService.getInstanceBaseUrl()}/signin?redirect=${encodeURIComponent(returnUrl)}`,
);
return;
}
if (user.id !== intent.userId) {
this.eventService.emit('dynamic-credential-authorize-rejected', {
reason: 'user-mismatch',
credentialId: intent.credentialId,
});
this.oauthService.renderCallbackError(
res,
'This authorization link was issued for a different account. Sign in as the intended user and open the link again.',
);
return;
}
}
try {
const credential = await this.findCredentialToUse(intent.credentialId);
const csrfData: CreateCsrfStateData = {
cid: credential.id,
origin: 'dynamic-credential',
authorizationHeader: intent.identity ? `Bearer ${intent.identity}` : '',
authMetadata: intent.metadata,
credentialResolverId: intent.resolverId,
userId: intent.userId,
};
const authorizationUrl = credential.type.toLowerCase().includes('oauth2')
? await this.oauthService.generateAOauth2AuthUri(credential, csrfData, req, res)
: await this.oauthService.generateAOauth1AuthUri(credential, csrfData, req, res);
res.redirect(authorizationUrl);
} catch (e) {
this.oauthService.renderCallbackError(res, ensureError(e).message);
}
}
/**
* DELETE /credentials/:credentialId/my-connection
*
* Deletes the running user's per-user connection row(s) for the given
* credential. Users can only disconnect their own connection — the absence
* of a `:userId` route segment is intentional.
*
* Not gated by `credential:read`: a user who has since lost access to the
* project (or to the credential) must still be able to clear their own
* stored OAuth tokens. The delete is keyed on the caller's userId server
* side, so the worst a user can do is clear their own row.
*/
@Delete('/:credentialId/my-connection')
async deleteMyConnection(
req: AuthenticatedRequest,
res: Response,
@Param('credentialId') credentialId: string,
): Promise<void> {
const credential = await this.credentialsFinderService.findById(credentialId);
if (!credential) {
throw new NotFoundError(NO_CONNECTION_TO_DISCONNECT);
}
const affected = await this.credentialConnectionStatusService.deleteMyConnection(
req.user.id,
credentialId,
);
if (affected === 0) {
throw new NotFoundError(NO_CONNECTION_TO_DISCONNECT);
}
this.eventService.emit('credentials-user-disconnected', {
user: req.user,
credentialId,
credentialType: credential.type,
});
res.status(204).send();
}
}