mirror of https://github.com/ghostfolio/ghostfolio
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
101 lines
3.8 KiB
101 lines
3.8 KiB
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
|
import { getAccessIdOfBearerToken } from '@ghostfolio/api/helper/bearer-token.helper';
|
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
|
import { HEADER_KEY_TOKEN, MCP_REALM } from '@ghostfolio/common/config';
|
|
import { hasRole } from '@ghostfolio/common/permissions';
|
|
import type { ImpersonationContext } from '@ghostfolio/common/types';
|
|
|
|
import { RequestClient } from '@prisma/client';
|
|
import { NextFunction, Request, Response } from 'express';
|
|
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
|
|
|
|
/**
|
|
* Authenticates a request of a client of the model context protocol before the
|
|
* transport reads it. The specification tells a server to answer an
|
|
* unauthorized request with the status 401 and a challenge, hence the answer
|
|
* cannot come from a guard: a guard runs while the transport dispatches the
|
|
* message and can no longer set the status of the response.
|
|
*
|
|
* The challenge names no resource metadata, because Ghostfolio has no
|
|
* authorization server. A client which reads the challenge asks the user for
|
|
* the identifier of the access instead of starting a flow which would fail.
|
|
*
|
|
* With the subscription, the user who granted the access has the same limits
|
|
* as with the other clients: an inactive user and a user who exceeds the daily
|
|
* request limit get the status 429, which also has to come from here.
|
|
*
|
|
* An authorized request is counted in the analytics of the user who granted
|
|
* the access, as the strategies do for the requests of the other clients.
|
|
*/
|
|
export function createMcpAuthorizationMiddleware({
|
|
configurationService,
|
|
impersonationService,
|
|
userService
|
|
}: {
|
|
configurationService: ConfigurationService;
|
|
impersonationService: ImpersonationService;
|
|
userService: UserService;
|
|
}) {
|
|
return async (
|
|
request: Request & { impersonationOfBearerToken?: ImpersonationContext },
|
|
response: Response,
|
|
next: NextFunction
|
|
) => {
|
|
const accessId = getAccessIdOfBearerToken(
|
|
request.headers[HEADER_KEY_TOKEN.toLowerCase()] as string
|
|
);
|
|
|
|
const impersonation = await impersonationService.resolve({
|
|
impersonationId: accessId,
|
|
types: ['MCP']
|
|
});
|
|
|
|
if (!impersonation.isActive) {
|
|
const parameters = [`realm="${MCP_REALM}"`];
|
|
|
|
if (accessId) {
|
|
parameters.push(
|
|
'error="invalid_token"',
|
|
'error_description="The access cannot be resolved"'
|
|
);
|
|
}
|
|
|
|
response.setHeader('WWW-Authenticate', `Bearer ${parameters.join(', ')}`);
|
|
|
|
return response.status(StatusCodes.UNAUTHORIZED).json({
|
|
error: getReasonPhrase(StatusCodes.UNAUTHORIZED),
|
|
message: 'A valid access is required as a bearer token'
|
|
});
|
|
}
|
|
|
|
if (configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
|
const user = await userService.user({ id: impersonation.userId });
|
|
|
|
// The daily requests of an inactive user are not counted, as in the
|
|
// strategies
|
|
const isRequestLimitExceeded =
|
|
hasRole(user, 'INACTIVE') ||
|
|
(await userService.isDailyRequestLimitExceeded({ user }));
|
|
|
|
if (isRequestLimitExceeded) {
|
|
return response.status(StatusCodes.TOO_MANY_REQUESTS).json({
|
|
error: getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
|
message:
|
|
'The request limit of the user who granted the access is exceeded'
|
|
});
|
|
}
|
|
|
|
await userService.updateAnalytics({
|
|
requestClient: RequestClient.MCP,
|
|
userId: impersonation.userId
|
|
});
|
|
}
|
|
|
|
// The guard of the route reads the resolved context from the same request,
|
|
// hence the access is looked up once
|
|
request.impersonationOfBearerToken = impersonation;
|
|
|
|
return next();
|
|
};
|
|
}
|
|
|