From 5a4b02a1f5196c88d07a59396b0e4209407306f8 Mon Sep 17 00:00:00 2001 From: Yannick Warnier Date: Tue, 28 Jul 2026 02:24:16 +0200 Subject: [PATCH 1/2] MCP: Add full OAuth2.1 server feature to enable auto-connect from MCP client - refs #8742 --- .../vue/components/social/UserProfileCard.vue | 28 ++ assets/vue/router/user.js | 6 + .../vue/services/oauthConnectedAppService.js | 18 + .../vue/views/user/AuthorizedApplications.vue | 146 +++++++ config/packages/framework.yaml | 16 + config/packages/nelmio_cors.yaml | 13 + config/packages/security.yaml | 19 +- .../OAuthServer/OAuthConnectedApp.php | 88 ++++ .../Command/OAuthGarbageCollectorCommand.php | 74 ++++ .../OAuthServer/OAuthAuthorizeController.php | 170 ++++++++ .../OAuthServer/OAuthDiscoveryController.php | 85 ++++ .../OAuthRegistrationController.php | 89 ++++ .../OAuthServer/OAuthRevocationController.php | 72 ++++ .../OAuthServer/OAuthTokenController.php | 84 ++++ .../DataFixtures/SettingsCurrentFixtures.php | 5 + .../Dto/OAuthServer/OAuthAuthorizeRequest.php | 38 ++ src/CoreBundle/Entity/OAuthAccessToken.php | 224 ++++++++++ .../Entity/OAuthAuthorizationCode.php | 269 ++++++++++++ src/CoreBundle/Entity/OAuthClient.php | 384 ++++++++++++++++++ src/CoreBundle/Entity/OAuthRefreshToken.php | 329 +++++++++++++++ .../Exception/OAuthServer/OAuthException.php | 105 +++++ .../Schema/V300/Version20260728120000.php | 188 +++++++++ .../Schema/V300/Version20260728130000.php | 310 ++++++++++++++ .../Repository/OAuthAccessTokenRepository.php | 100 +++++ .../OAuthAuthorizationCodeRepository.php | 66 +++ .../Repository/OAuthClientRepository.php | 84 ++++ .../OAuthRefreshTokenRepository.php | 136 +++++++ .../views/OAuthServer/consent.html.twig | 51 +++ .../views/OAuthServer/error.html.twig | 15 + .../Authenticator/McpBearerAuthenticator.php | 87 +++- .../OAuthServer/OAuthAuthorizationService.php | 184 +++++++++ .../OAuthServer/OAuthClientRegistrar.php | 202 +++++++++ .../OAuthServer/OAuthClientResolver.php | 90 ++++ .../Service/OAuthServer/OAuthGrantManager.php | 108 +++++ .../OAuthServer/OAuthMetadataService.php | 119 ++++++ .../Service/OAuthServer/OAuthTokenService.php | 320 +++++++++++++++ .../Settings/SecuritySettingsSchema.php | 2 + .../OAuthConnectedAppProcessor.php | 34 ++ .../OAuthServer/OAuthConnectedAppProvider.php | 33 ++ .../Api/OAuthConnectedAppApiSecurityTest.php | 142 +++++++ .../OAuthTokenPrefixCollisionTest.php | 46 +++ .../OAuthServer/OAuthClientRegistrarTest.php | 139 +++++++ 42 files changed, 4715 insertions(+), 3 deletions(-) create mode 100644 assets/vue/services/oauthConnectedAppService.js create mode 100644 assets/vue/views/user/AuthorizedApplications.vue create mode 100644 src/CoreBundle/ApiResource/OAuthServer/OAuthConnectedApp.php create mode 100644 src/CoreBundle/Command/OAuthGarbageCollectorCommand.php create mode 100644 src/CoreBundle/Controller/OAuthServer/OAuthAuthorizeController.php create mode 100644 src/CoreBundle/Controller/OAuthServer/OAuthDiscoveryController.php create mode 100644 src/CoreBundle/Controller/OAuthServer/OAuthRegistrationController.php create mode 100644 src/CoreBundle/Controller/OAuthServer/OAuthRevocationController.php create mode 100644 src/CoreBundle/Controller/OAuthServer/OAuthTokenController.php create mode 100644 src/CoreBundle/Dto/OAuthServer/OAuthAuthorizeRequest.php create mode 100644 src/CoreBundle/Entity/OAuthAccessToken.php create mode 100644 src/CoreBundle/Entity/OAuthAuthorizationCode.php create mode 100644 src/CoreBundle/Entity/OAuthClient.php create mode 100644 src/CoreBundle/Entity/OAuthRefreshToken.php create mode 100644 src/CoreBundle/Exception/OAuthServer/OAuthException.php create mode 100644 src/CoreBundle/Migrations/Schema/V300/Version20260728120000.php create mode 100644 src/CoreBundle/Migrations/Schema/V300/Version20260728130000.php create mode 100644 src/CoreBundle/Repository/OAuthAccessTokenRepository.php create mode 100644 src/CoreBundle/Repository/OAuthAuthorizationCodeRepository.php create mode 100644 src/CoreBundle/Repository/OAuthClientRepository.php create mode 100644 src/CoreBundle/Repository/OAuthRefreshTokenRepository.php create mode 100644 src/CoreBundle/Resources/views/OAuthServer/consent.html.twig create mode 100644 src/CoreBundle/Resources/views/OAuthServer/error.html.twig create mode 100644 src/CoreBundle/Service/OAuthServer/OAuthAuthorizationService.php create mode 100644 src/CoreBundle/Service/OAuthServer/OAuthClientRegistrar.php create mode 100644 src/CoreBundle/Service/OAuthServer/OAuthClientResolver.php create mode 100644 src/CoreBundle/Service/OAuthServer/OAuthGrantManager.php create mode 100644 src/CoreBundle/Service/OAuthServer/OAuthMetadataService.php create mode 100644 src/CoreBundle/Service/OAuthServer/OAuthTokenService.php create mode 100644 src/CoreBundle/State/OAuthServer/OAuthConnectedAppProcessor.php create mode 100644 src/CoreBundle/State/OAuthServer/OAuthConnectedAppProvider.php create mode 100644 tests/CoreBundle/Api/OAuthConnectedAppApiSecurityTest.php create mode 100644 tests/CoreBundle/Security/OAuthTokenPrefixCollisionTest.php create mode 100644 tests/CoreBundle/Service/OAuthServer/OAuthClientRegistrarTest.php diff --git a/assets/vue/components/social/UserProfileCard.vue b/assets/vue/components/social/UserProfileCard.vue index 45f2bfe00ea..fbecce08a00 100644 --- a/assets/vue/components/social/UserProfileCard.vue +++ b/assets/vue/components/social/UserProfileCard.vue @@ -309,6 +309,30 @@ aria-hidden="true" > + + @@ -382,6 +406,10 @@ function manageMcpApiKey() { window.location.href = "/resources/users/mcp_api_key" } +function manageAuthorizedApplications() { + window.location.href = "/resources/users/authorized_apps" +} + async function fetchUserProfile(userId) { try { const data = await socialService.getUserProfile(userId) diff --git a/assets/vue/router/user.js b/assets/vue/router/user.js index 8c842d04031..26c312cff04 100644 --- a/assets/vue/router/user.js +++ b/assets/vue/router/user.js @@ -23,5 +23,11 @@ export default { meta: { breadcrumb: "MCP API key" }, component: () => import("../views/user/McpApiKey.vue"), }, + { + name: "AuthorizedApplications", + path: "authorized_apps", + meta: { breadcrumb: "Authorized applications" }, + component: () => import("../views/user/AuthorizedApplications.vue"), + }, ], } diff --git a/assets/vue/services/oauthConnectedAppService.js b/assets/vue/services/oauthConnectedAppService.js new file mode 100644 index 00000000000..3a8b39c3c91 --- /dev/null +++ b/assets/vue/services/oauthConnectedAppService.js @@ -0,0 +1,18 @@ +import baseService from "./baseService" + +const ENDPOINT = "/api/oauth_connected_apps" + +async function list() { + const { items } = await baseService.getCollection(ENDPOINT) + + return items +} + +async function revoke(id) { + return baseService.delete(`${ENDPOINT}/${id}`) +} + +export default { + list, + revoke, +} diff --git a/assets/vue/views/user/AuthorizedApplications.vue b/assets/vue/views/user/AuthorizedApplications.vue new file mode 100644 index 00000000000..72ea7c7218d --- /dev/null +++ b/assets/vue/views/user/AuthorizedApplications.vue @@ -0,0 +1,146 @@ + + + diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 4e20faab179..ccabee98254 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -41,6 +41,22 @@ framework: policy: 'sliding_window' limit: 120 interval: '1 minute' + oauth_registration: + policy: 'sliding_window' + limit: 5 + interval: '1 hour' + oauth_authorization: + policy: 'sliding_window' + limit: 30 + interval: '1 minute' + oauth_token: + policy: 'sliding_window' + limit: 60 + interval: '1 minute' + oauth_revocation: + policy: 'sliding_window' + limit: 30 + interval: '1 minute' # For legacy code (ending in ".php"), also edit public/main/inc/global.inc.php according to the changes you make here diff --git a/config/packages/nelmio_cors.yaml b/config/packages/nelmio_cors.yaml index aaa86e72233..0c30426f337 100644 --- a/config/packages/nelmio_cors.yaml +++ b/config/packages/nelmio_cors.yaml @@ -8,6 +8,19 @@ nelmio_cors: max_age: 3600 allow_credentials: true paths: + '^/(\.well-known/oauth-|oauth/(token|register|revoke)$)': + allow_origin: ['*'] + allow_credentials: false + allow_methods: ['GET', 'POST', 'OPTIONS'] + allow_headers: ['Content-Type', 'Authorization', 'MCP-Protocol-Version'] + max_age: 3600 + '^/mcp$': + allow_origin: ['*'] + allow_credentials: false + allow_methods: ['GET', 'POST', 'DELETE', 'OPTIONS'] + allow_headers: ['Content-Type', 'Authorization', 'MCP-Protocol-Version', 'Mcp-Session-Id', 'Last-Event-ID'] + expose_headers: ['WWW-Authenticate', 'Mcp-Session-Id'] + max_age: 3600 '^/login/token/check': origin_regex: true allow_origin: ['%env(CORS_ALLOW_ORIGIN)%'] diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 08b0e66a441..29090dc2945 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -68,7 +68,9 @@ security: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false - # MCP remote server authenticated with a personal MCP API key or a legacy development JWT. + # MCP remote server authenticated with a personal MCP API key, an OAuth + # access token issued by the oauth_server firewall below, or a legacy + # development JWT. mcp: pattern: ^/mcp$ stateless: true @@ -77,6 +79,17 @@ security: custom_authenticators: - Chamilo\CoreBundle\Security\Authenticator\McpBearerAuthenticator + # Generic OAuth 2.1 Authorization Server surface (discovery, dynamic + # client registration, token issuance/refresh, revocation). These + # authenticate the *client application* (or an explicit secret in the + # request body), never a Chamilo user session — deliberately stateless + # and carries no custom authenticators. /oauth/authorize is NOT here: + # it needs the real session cookie from /login and stays under `main`. + oauth_server: + pattern: ^/(\.well-known/oauth-(protected-resource|authorization-server)|oauth/(token|register|revoke)) + stateless: true + provider: app_user_provider + # Use to connect via a JWT token api: pattern: ^/api @@ -141,6 +154,10 @@ security: access_control: - { path: '^/mcp$', roles: PUBLIC_ACCESS, methods: [OPTIONS] } - { path: '^/mcp$', roles: ROLE_USER } + - { path: '^/\.well-known/oauth-', roles: PUBLIC_ACCESS } + - { path: '^/oauth/(token|register|revoke)$', roles: PUBLIC_ACCESS } + - { path: '^/oauth/authorize$', roles: PUBLIC_ACCESS, methods: [GET] } + - { path: '^/oauth/authorize$', roles: ROLE_USER, methods: [POST] } - { path: ^/scim/v2, roles: PUBLIC_ACCESS } - { path: ^/login/token/check, roles: PUBLIC_ACCESS } - { path: ^/login, roles: PUBLIC_ACCESS } diff --git a/src/CoreBundle/ApiResource/OAuthServer/OAuthConnectedApp.php b/src/CoreBundle/ApiResource/OAuthServer/OAuthConnectedApp.php new file mode 100644 index 00000000000..c3468c9158d --- /dev/null +++ b/src/CoreBundle/ApiResource/OAuthServer/OAuthConnectedApp.php @@ -0,0 +1,88 @@ + ['oauth_connected_app:read']], +)] +final class OAuthConnectedApp +{ + #[ApiProperty(identifier: true)] + #[Groups(['oauth_connected_app:read'])] + public string $id = ''; + + #[Groups(['oauth_connected_app:read'])] + public string $clientName = ''; + + #[Groups(['oauth_connected_app:read'])] + public ?string $clientUri = null; + + #[Groups(['oauth_connected_app:read'])] + public ?string $connectedAt = null; + + #[Groups(['oauth_connected_app:read'])] + public ?string $lastUsedAt = null; + + #[Groups(['oauth_connected_app:read'])] + public ?string $expiresAt = null; + + #[Groups(['oauth_connected_app:read'])] + public ?string $scope = null; + + public function getId(): string + { + return $this->id; + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $resource = new self(); + $resource->id = (string) ($data['id'] ?? ''); + $resource->clientName = (string) ($data['clientName'] ?? ''); + $resource->clientUri = isset($data['clientUri']) ? (string) $data['clientUri'] : null; + $resource->connectedAt = isset($data['connectedAt']) ? (string) $data['connectedAt'] : null; + $resource->lastUsedAt = isset($data['lastUsedAt']) ? (string) $data['lastUsedAt'] : null; + $resource->expiresAt = isset($data['expiresAt']) ? (string) $data['expiresAt'] : null; + $resource->scope = isset($data['scope']) ? (string) $data['scope'] : null; + + return $resource; + } +} diff --git a/src/CoreBundle/Command/OAuthGarbageCollectorCommand.php b/src/CoreBundle/Command/OAuthGarbageCollectorCommand.php new file mode 100644 index 00000000000..b323b3efe4b --- /dev/null +++ b/src/CoreBundle/Command/OAuthGarbageCollectorCommand.php @@ -0,0 +1,74 @@ +codeRepository->deleteExpired($now); + $io->writeln(\sprintf('Deleted %d expired authorization codes.', $deletedCodes)); + + $accessCutoff = (clone $now)->modify('-'.self::REVOKED_TOKEN_RETENTION_DAYS.' days'); + $deletedAccessTokens = $this->accessTokenRepository->deleteExpired($accessCutoff); + $io->writeln(\sprintf('Deleted %d expired access tokens.', $deletedAccessTokens)); + + $refreshCutoff = (clone $now)->modify('-'.self::EXPIRED_REFRESH_RETENTION_DAYS.' days'); + $deletedRefreshTokens = $this->refreshTokenRepository->deleteExpired($refreshCutoff); + $io->writeln(\sprintf('Deleted %d expired refresh tokens.', $deletedRefreshTokens)); + + $clientCutoff = (clone $now)->modify('-'.self::UNUSED_CLIENT_RETENTION_DAYS.' days'); + $staleClients = $this->clientRepository->findStaleUnusedClients($clientCutoff); + foreach ($staleClients as $client) { + $client->setRevokedAt($now); + } + $this->entityManager->flush(); + $io->writeln(\sprintf('Revoked %d unused registered clients.', \count($staleClients))); + + $io->success('OAuth garbage collection complete.'); + + return Command::SUCCESS; + } +} diff --git a/src/CoreBundle/Controller/OAuthServer/OAuthAuthorizeController.php b/src/CoreBundle/Controller/OAuthServer/OAuthAuthorizeController.php new file mode 100644 index 00000000000..d472101893f --- /dev/null +++ b/src/CoreBundle/Controller/OAuthServer/OAuthAuthorizeController.php @@ -0,0 +1,170 @@ +assertEnabled(); + $this->consumeRateLimit($request); + + try { + $resolved = $this->authorizationService->resolveClientAndRedirectUri($request); + } catch (RuntimeException $exception) { + return $this->renderError($exception->getMessage()); + } + + $client = $resolved['client']; + $redirectUri = $resolved['redirectUri']; + + try { + $authorizeRequest = $this->authorizationService->validateAuthorizeParameters($request); + } catch (OAuthException $exception) { + return new RedirectResponse($this->authorizationService->buildRedirectUrl($redirectUri, [ + 'error' => $exception->getErrorCode(), + 'error_description' => $exception->getMessage(), + 'state' => (string) $request->query->get('state', ''), + ])); + } + + $this->authorizationService->stashInSession($request, $authorizeRequest); + + $user = $this->getUser(); + if (!$user instanceof User) { + $queryString = $request->getQueryString(); + $target = $request->getPathInfo().(null !== $queryString && '' !== $queryString ? '?'.$queryString : ''); + + return new RedirectResponse('/login?redirect='.rawurlencode($target)); + } + + if (!$this->authorizationService->assertUserActiveOnCurrentPortal($user)) { + return $this->renderError('Your account is not currently active on this Chamilo portal.'); + } + + return $this->render('@ChamiloCore/OAuthServer/consent.html.twig', [ + 'client' => $client, + 'oauth_user' => $user, + 'redirect_uri' => $redirectUri, + 'resource' => $authorizeRequest->resource, + 'csrf_token' => $this->csrfTokenManager->getToken(self::CSRF_INTENT)->getValue(), + ]); + } + + #[Route('/oauth/authorize', name: 'oauth_authorize_consent', methods: ['POST'])] + public function consent(Request $request): Response + { + $this->assertEnabled(); + $this->consumeRateLimit($request); + + $token = (string) $request->request->get('_token', ''); + if (!$this->csrfTokenManager->isTokenValid(new CsrfToken(self::CSRF_INTENT, $token))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + + $authorizeRequest = $this->authorizationService->popFromSession($request); + if (null === $authorizeRequest + || $authorizeRequest->isStale(time(), OAuthAuthorizationService::PENDING_REQUEST_TTL_SECONDS) + ) { + return $this->renderError('Your authorization request has expired. Please start again.'); + } + + $client = $this->clientResolver->resolveActive($authorizeRequest->clientId); + if (null === $client || !$client->supportsRedirectUri($authorizeRequest->redirectUri)) { + return $this->renderError('This application is no longer available. Please start again.'); + } + + $user = $this->getUser(); + if (!$user instanceof User) { + throw $this->createAccessDeniedException('Authentication is required.'); + } + + if (!$this->authorizationService->assertUserActiveOnCurrentPortal($user)) { + return $this->renderError('Your account is not currently active on this Chamilo portal.'); + } + + $action = (string) $request->request->get('action', ''); + + if ('deny' === $action) { + return new RedirectResponse($this->authorizationService->buildRedirectUrl($authorizeRequest->redirectUri, [ + 'error' => 'access_denied', + 'state' => $authorizeRequest->state, + ])); + } + + if ('allow' !== $action) { + return $this->renderError('Invalid request.'); + } + + $code = $this->authorizationService->issueCode($client, $authorizeRequest, $user, $request); + + $response = new RedirectResponse($this->authorizationService->buildRedirectUrl($authorizeRequest->redirectUri, [ + 'code' => $code, + 'state' => $authorizeRequest->state, + ])); + $response->headers->set('Cache-Control', 'no-store'); + + return $response; + } + + private function consumeRateLimit(Request $request): void + { + $limiter = $this->oauthAuthorizationLimiter->create((string) $request->getClientIp()); + if (!$limiter->consume()->isAccepted()) { + throw new TooManyRequestsHttpException(); + } + } + + private function assertEnabled(): void + { + if ('true' !== $this->settingsManager->getSetting('security.oauth_server_enabled')) { + throw new NotFoundHttpException(); + } + } + + private function renderError(string $message): Response + { + return $this->render( + '@ChamiloCore/OAuthServer/error.html.twig', + ['message' => $message], + new Response('', Response::HTTP_BAD_REQUEST), + ); + } +} diff --git a/src/CoreBundle/Controller/OAuthServer/OAuthDiscoveryController.php b/src/CoreBundle/Controller/OAuthServer/OAuthDiscoveryController.php new file mode 100644 index 00000000000..1ab65c76d57 --- /dev/null +++ b/src/CoreBundle/Controller/OAuthServer/OAuthDiscoveryController.php @@ -0,0 +1,85 @@ + '.+'], + methods: ['GET'], + )] + public function protectedResourceMetadata(string $resourcePath = 'mcp'): JsonResponse + { + $this->assertEnabled(); + + return $this->cached( + new JsonResponse($this->metadata->buildProtectedResourceMetadata($resourcePath)), + ); + } + + #[Route( + '/.well-known/oauth-authorization-server', + name: 'oauth_authorization_server_metadata', + methods: ['GET'], + )] + #[Route( + '/.well-known/oauth-authorization-server/{resourcePath}', + name: 'oauth_authorization_server_metadata_scoped', + requirements: ['resourcePath' => '.+'], + methods: ['GET'], + )] + public function authorizationServerMetadata(): JsonResponse + { + $this->assertEnabled(); + + return $this->cached( + new JsonResponse($this->metadata->buildAuthorizationServerMetadata()), + ); + } + + private function cached(JsonResponse $response): JsonResponse + { + $response->headers->set('Cache-Control', 'public, max-age=3600'); + + return $response; + } + + private function assertEnabled(): void + { + if ('true' !== $this->settingsManager->getSetting('security.oauth_server_enabled')) { + throw new NotFoundHttpException(); + } + } +} diff --git a/src/CoreBundle/Controller/OAuthServer/OAuthRegistrationController.php b/src/CoreBundle/Controller/OAuthServer/OAuthRegistrationController.php new file mode 100644 index 00000000000..fa9c085819c --- /dev/null +++ b/src/CoreBundle/Controller/OAuthServer/OAuthRegistrationController.php @@ -0,0 +1,89 @@ +settingsManager->getSetting('security.oauth_server_enabled')) { + throw new NotFoundHttpException(); + } + + $limiter = $this->oauthRegistrationLimiter->create($request->getClientIp()); + if (!$limiter->consume()->isAccepted()) { + throw new TooManyRequestsHttpException(); + } + + $content = $request->getContent(); + if (mb_strlen($content) > self::MAX_BODY_BYTES) { + throw new BadRequestHttpException('The registration request body is too large.'); + } + + try { + $metadata = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + $metadata = null; + } + + if (!\is_array($metadata)) { + return $this->errorResponse('invalid_client_metadata', 'The request body must contain a valid JSON object.', 400); + } + + try { + $response = $this->registrar->register($metadata, (string) $request->getClientIp()); + } catch (OAuthException $exception) { + return $this->errorResponse($exception->getErrorCode(), $exception->getMessage(), $exception->getHttpStatusCode()); + } + + $json = new JsonResponse($response, Response::HTTP_CREATED); + $json->headers->set('Cache-Control', 'no-store'); + $json->headers->set('Pragma', 'no-cache'); + + return $json; + } + + private function errorResponse(string $error, string $description, int $status): JsonResponse + { + $response = new JsonResponse(['error' => $error, 'error_description' => $description], $status); + $response->headers->set('Cache-Control', 'no-store'); + $response->headers->set('Pragma', 'no-cache'); + + return $response; + } +} diff --git a/src/CoreBundle/Controller/OAuthServer/OAuthRevocationController.php b/src/CoreBundle/Controller/OAuthServer/OAuthRevocationController.php new file mode 100644 index 00000000000..469d0eb72be --- /dev/null +++ b/src/CoreBundle/Controller/OAuthServer/OAuthRevocationController.php @@ -0,0 +1,72 @@ +settingsManager->getSetting('security.oauth_server_enabled')) { + throw new NotFoundHttpException(); + } + + $limiter = $this->oauthRevocationLimiter->create((string) $request->getClientIp()); + if (!$limiter->consume()->isAccepted()) { + throw new TooManyRequestsHttpException(); + } + + $token = (string) $request->request->get('token', ''); + $tokenTypeHint = $request->request->get('token_type_hint'); + $tokenTypeHint = \is_string($tokenTypeHint) ? $tokenTypeHint : null; + + try { + $client = $this->clientResolver->authenticateFromRequest($request); + + if ('' !== $token) { + $this->tokenService->revoke($client, $token, $tokenTypeHint); + } + } catch (OAuthException $exception) { + $response = new Response('', $exception->getHttpStatusCode()); + foreach ($exception->getExtraHeaders() as $name => $value) { + $response->headers->set($name, $value); + } + + return $response; + } + + $response = new Response('', Response::HTTP_OK); + $response->headers->set('Cache-Control', 'no-store'); + + return $response; + } +} diff --git a/src/CoreBundle/Controller/OAuthServer/OAuthTokenController.php b/src/CoreBundle/Controller/OAuthServer/OAuthTokenController.php new file mode 100644 index 00000000000..8d6cc6abdff --- /dev/null +++ b/src/CoreBundle/Controller/OAuthServer/OAuthTokenController.php @@ -0,0 +1,84 @@ +settingsManager->getSetting('security.oauth_server_enabled')) { + throw new NotFoundHttpException(); + } + + $limiter = $this->oauthTokenLimiter->create((string) $request->getClientIp()); + if (!$limiter->consume()->isAccepted()) { + throw new TooManyRequestsHttpException(); + } + + try { + $client = $this->clientResolver->authenticateFromRequest($request); + + $grantType = (string) $request->request->get('grant_type', ''); + $result = match ($grantType) { + 'authorization_code' => $this->tokenService->exchangeAuthorizationCode($client, $request->request->all()), + 'refresh_token' => $this->tokenService->refresh($client, $request->request->all()), + default => throw OAuthException::unsupportedGrantType(), + }; + } catch (OAuthException $exception) { + return $this->errorResponse($exception); + } + + $response = new JsonResponse($result); + $response->headers->set('Cache-Control', 'no-store'); + $response->headers->set('Pragma', 'no-cache'); + + return $response; + } + + private function errorResponse(OAuthException $exception): JsonResponse + { + $response = new JsonResponse( + ['error' => $exception->getErrorCode(), 'error_description' => $exception->getMessage()], + $exception->getHttpStatusCode(), + ); + $response->headers->set('Cache-Control', 'no-store'); + $response->headers->set('Pragma', 'no-cache'); + + foreach ($exception->getExtraHeaders() as $name => $value) { + $response->headers->set($name, $value); + } + + return $response; + } +} diff --git a/src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php b/src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php index da43d519207..47076d6ec5d 100644 --- a/src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php +++ b/src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php @@ -3158,6 +3158,11 @@ public static function getNewConfigurationSettings(): array 'title' => 'File integrity check notification recipients', 'comment' => 'Comma-separated list of e-mail addresses to notify when a file integrity scan detects a change. Leave empty to notify every global administrator instead.', ], + [ + 'name' => 'oauth_server_enabled', + 'title' => 'Enable the OAuth authorization server', + 'comment' => 'Allows external applications (e.g. the Claude.ai MCP connector) to register via OAuth 2.1 Dynamic Client Registration and connect using each user\'s own Chamilo account and permissions. Disabled by default: the discovery, registration, authorization and token endpoints all return 404 until this is turned on.', + ], ], 'session' => [ [ diff --git a/src/CoreBundle/Dto/OAuthServer/OAuthAuthorizeRequest.php b/src/CoreBundle/Dto/OAuthServer/OAuthAuthorizeRequest.php new file mode 100644 index 00000000000..9ce8907c892 --- /dev/null +++ b/src/CoreBundle/Dto/OAuthServer/OAuthAuthorizeRequest.php @@ -0,0 +1,38 @@ +createdAt + $ttlSeconds < $now; + } +} diff --git a/src/CoreBundle/Entity/OAuthAccessToken.php b/src/CoreBundle/Entity/OAuthAccessToken.php new file mode 100644 index 00000000000..6d3c87cde06 --- /dev/null +++ b/src/CoreBundle/Entity/OAuthAccessToken.php @@ -0,0 +1,224 @@ +id; + } + + public function getTokenHash(): string + { + return $this->tokenHash; + } + + public function setTokenHash(string $tokenHash): self + { + $this->tokenHash = $tokenHash; + + return $this; + } + + public function getTokenPrefix(): ?string + { + return $this->tokenPrefix; + } + + public function setTokenPrefix(?string $tokenPrefix): self + { + $this->tokenPrefix = $tokenPrefix; + + return $this; + } + + public function getGrantId(): string + { + return $this->grantId; + } + + public function setGrantId(string $grantId): self + { + $this->grantId = $grantId; + + return $this; + } + + public function getClient(): OAuthClient + { + return $this->client; + } + + public function setClient(OAuthClient $client): self + { + $this->client = $client; + + return $this; + } + + public function getUser(): User + { + return $this->user; + } + + public function setUser(User $user): self + { + $this->user = $user; + + return $this; + } + + public function getAccessUrlId(): ?int + { + return $this->accessUrlId; + } + + public function setAccessUrlId(?int $accessUrlId): self + { + $this->accessUrlId = $accessUrlId; + + return $this; + } + + public function getScope(): ?string + { + return $this->scope; + } + + public function setScope(?string $scope): self + { + $this->scope = $scope; + + return $this; + } + + public function getResource(): ?string + { + return $this->resource; + } + + public function setResource(?string $resource): self + { + $this->resource = $resource; + + return $this; + } + + public function getCreatedAt(): DateTime + { + return $this->createdAt; + } + + public function setCreatedAt(DateTime $createdAt): self + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getExpiresAt(): DateTime + { + return $this->expiresAt; + } + + public function setExpiresAt(DateTime $expiresAt): self + { + $this->expiresAt = $expiresAt; + + return $this; + } + + public function getLastUsedAt(): ?DateTime + { + return $this->lastUsedAt; + } + + public function setLastUsedAt(?DateTime $lastUsedAt): self + { + $this->lastUsedAt = $lastUsedAt; + + return $this; + } + + public function getRevokedAt(): ?DateTime + { + return $this->revokedAt; + } + + public function setRevokedAt(?DateTime $revokedAt): self + { + $this->revokedAt = $revokedAt; + + return $this; + } + + public function isActiveAt(DateTime $date): bool + { + return null === $this->revokedAt && $this->expiresAt > $date; + } +} diff --git a/src/CoreBundle/Entity/OAuthAuthorizationCode.php b/src/CoreBundle/Entity/OAuthAuthorizationCode.php new file mode 100644 index 00000000000..138b91c7e10 --- /dev/null +++ b/src/CoreBundle/Entity/OAuthAuthorizationCode.php @@ -0,0 +1,269 @@ +id; + } + + public function getCodeHash(): string + { + return $this->codeHash; + } + + public function setCodeHash(string $codeHash): self + { + $this->codeHash = $codeHash; + + return $this; + } + + public function getGrantId(): string + { + return $this->grantId; + } + + public function setGrantId(string $grantId): self + { + $this->grantId = $grantId; + + return $this; + } + + public function getClient(): OAuthClient + { + return $this->client; + } + + public function setClient(OAuthClient $client): self + { + $this->client = $client; + + return $this; + } + + public function getUser(): User + { + return $this->user; + } + + public function setUser(User $user): self + { + $this->user = $user; + + return $this; + } + + public function getAccessUrlId(): ?int + { + return $this->accessUrlId; + } + + public function setAccessUrlId(?int $accessUrlId): self + { + $this->accessUrlId = $accessUrlId; + + return $this; + } + + public function getRedirectUri(): string + { + return $this->redirectUri; + } + + public function setRedirectUri(string $redirectUri): self + { + $this->redirectUri = $redirectUri; + + return $this; + } + + public function getCodeChallenge(): string + { + return $this->codeChallenge; + } + + public function setCodeChallenge(string $codeChallenge): self + { + $this->codeChallenge = $codeChallenge; + + return $this; + } + + public function getCodeChallengeMethod(): string + { + return $this->codeChallengeMethod; + } + + public function setCodeChallengeMethod(string $codeChallengeMethod): self + { + $this->codeChallengeMethod = $codeChallengeMethod; + + return $this; + } + + public function getScope(): ?string + { + return $this->scope; + } + + public function setScope(?string $scope): self + { + $this->scope = $scope; + + return $this; + } + + public function getResource(): ?string + { + return $this->resource; + } + + public function setResource(?string $resource): self + { + $this->resource = $resource; + + return $this; + } + + public function getConsentIp(): ?string + { + return $this->consentIp; + } + + public function setConsentIp(?string $consentIp): self + { + $this->consentIp = $consentIp; + + return $this; + } + + public function getConsentUserAgent(): ?string + { + return $this->consentUserAgent; + } + + public function setConsentUserAgent(?string $consentUserAgent): self + { + $this->consentUserAgent = $consentUserAgent; + + return $this; + } + + public function getCreatedAt(): DateTime + { + return $this->createdAt; + } + + public function setCreatedAt(DateTime $createdAt): self + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getExpiresAt(): DateTime + { + return $this->expiresAt; + } + + public function setExpiresAt(DateTime $expiresAt): self + { + $this->expiresAt = $expiresAt; + + return $this; + } + + public function getUsedAt(): ?DateTime + { + return $this->usedAt; + } + + public function setUsedAt(?DateTime $usedAt): self + { + $this->usedAt = $usedAt; + + return $this; + } + + public function isActiveAt(DateTime $date): bool + { + return null === $this->usedAt && $this->expiresAt > $date; + } +} diff --git a/src/CoreBundle/Entity/OAuthClient.php b/src/CoreBundle/Entity/OAuthClient.php new file mode 100644 index 00000000000..1dfd5b56278 --- /dev/null +++ b/src/CoreBundle/Entity/OAuthClient.php @@ -0,0 +1,384 @@ + + */ + #[ORM\Column(name: 'redirect_uris', type: 'json', nullable: false)] + protected array $redirectUris = []; + + /** + * @var array + */ + #[ORM\Column(name: 'grant_types', type: 'json', nullable: false)] + protected array $grantTypes = []; + + /** + * @var array + */ + #[ORM\Column(name: 'response_types', type: 'json', nullable: false)] + protected array $responseTypes = []; + + #[ORM\Column(name: 'scope', type: 'string', length: 255, nullable: true)] + protected ?string $scope = null; + + #[ORM\Column(name: 'access_url_id', type: 'integer', nullable: true)] + protected ?int $accessUrlId = null; + + #[ORM\Column(name: 'registration_ip', type: 'string', length: 45, nullable: true)] + protected ?string $registrationIp = null; + + #[ORM\Column(name: 'created_at', type: 'datetime', nullable: false)] + protected DateTime $createdAt; + + #[ORM\Column(name: 'last_used_at', type: 'datetime', nullable: true)] + protected ?DateTime $lastUsedAt = null; + + #[ORM\Column(name: 'revoked_at', type: 'datetime', nullable: true)] + protected ?DateTime $revokedAt = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getClientId(): string + { + return $this->clientId; + } + + public function setClientId(string $clientId): self + { + $this->clientId = $clientId; + + return $this; + } + + public function getClientSecretHash(): ?string + { + return $this->clientSecretHash; + } + + public function setClientSecretHash(?string $clientSecretHash): self + { + $this->clientSecretHash = $clientSecretHash; + + return $this; + } + + public function getClientSecretPrefix(): ?string + { + return $this->clientSecretPrefix; + } + + public function setClientSecretPrefix(?string $clientSecretPrefix): self + { + $this->clientSecretPrefix = $clientSecretPrefix; + + return $this; + } + + public function getTokenEndpointAuthMethod(): string + { + return $this->tokenEndpointAuthMethod; + } + + public function setTokenEndpointAuthMethod(string $tokenEndpointAuthMethod): self + { + $this->tokenEndpointAuthMethod = $tokenEndpointAuthMethod; + + return $this; + } + + public function getClientName(): ?string + { + return $this->clientName; + } + + public function setClientName(?string $clientName): self + { + $this->clientName = $clientName; + + return $this; + } + + public function getClientUri(): ?string + { + return $this->clientUri; + } + + public function setClientUri(?string $clientUri): self + { + $this->clientUri = $clientUri; + + return $this; + } + + public function getLogoUri(): ?string + { + return $this->logoUri; + } + + public function setLogoUri(?string $logoUri): self + { + $this->logoUri = $logoUri; + + return $this; + } + + public function getPolicyUri(): ?string + { + return $this->policyUri; + } + + public function setPolicyUri(?string $policyUri): self + { + $this->policyUri = $policyUri; + + return $this; + } + + public function getTosUri(): ?string + { + return $this->tosUri; + } + + public function setTosUri(?string $tosUri): self + { + $this->tosUri = $tosUri; + + return $this; + } + + public function getSoftwareId(): ?string + { + return $this->softwareId; + } + + public function setSoftwareId(?string $softwareId): self + { + $this->softwareId = $softwareId; + + return $this; + } + + public function getSoftwareVersion(): ?string + { + return $this->softwareVersion; + } + + public function setSoftwareVersion(?string $softwareVersion): self + { + $this->softwareVersion = $softwareVersion; + + return $this; + } + + /** + * @return array + */ + public function getRedirectUris(): array + { + return $this->redirectUris; + } + + /** + * @param array $redirectUris + */ + public function setRedirectUris(array $redirectUris): self + { + $this->redirectUris = $redirectUris; + + return $this; + } + + /** + * @return array + */ + public function getGrantTypes(): array + { + return $this->grantTypes; + } + + /** + * @param array $grantTypes + */ + public function setGrantTypes(array $grantTypes): self + { + $this->grantTypes = $grantTypes; + + return $this; + } + + /** + * @return array + */ + public function getResponseTypes(): array + { + return $this->responseTypes; + } + + /** + * @param array $responseTypes + */ + public function setResponseTypes(array $responseTypes): self + { + $this->responseTypes = $responseTypes; + + return $this; + } + + public function getScope(): ?string + { + return $this->scope; + } + + public function setScope(?string $scope): self + { + $this->scope = $scope; + + return $this; + } + + public function getAccessUrlId(): ?int + { + return $this->accessUrlId; + } + + public function setAccessUrlId(?int $accessUrlId): self + { + $this->accessUrlId = $accessUrlId; + + return $this; + } + + public function getRegistrationIp(): ?string + { + return $this->registrationIp; + } + + public function setRegistrationIp(?string $registrationIp): self + { + $this->registrationIp = $registrationIp; + + return $this; + } + + public function getCreatedAt(): DateTime + { + return $this->createdAt; + } + + public function setCreatedAt(DateTime $createdAt): self + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getLastUsedAt(): ?DateTime + { + return $this->lastUsedAt; + } + + public function setLastUsedAt(?DateTime $lastUsedAt): self + { + $this->lastUsedAt = $lastUsedAt; + + return $this; + } + + public function getRevokedAt(): ?DateTime + { + return $this->revokedAt; + } + + public function setRevokedAt(?DateTime $revokedAt): self + { + $this->revokedAt = $revokedAt; + + return $this; + } + + public function isPublicClient(): bool + { + return 'none' === $this->tokenEndpointAuthMethod; + } + + public function isActiveAt(DateTime $date): bool + { + return null === $this->revokedAt; + } + + public function supportsRedirectUri(string $redirectUri): bool + { + foreach ($this->redirectUris as $registered) { + if (hash_equals($registered, $redirectUri)) { + return true; + } + } + + return false; + } +} diff --git a/src/CoreBundle/Entity/OAuthRefreshToken.php b/src/CoreBundle/Entity/OAuthRefreshToken.php new file mode 100644 index 00000000000..5d615dffad2 --- /dev/null +++ b/src/CoreBundle/Entity/OAuthRefreshToken.php @@ -0,0 +1,329 @@ +id; + } + + public function getTokenHash(): string + { + return $this->tokenHash; + } + + public function setTokenHash(string $tokenHash): self + { + $this->tokenHash = $tokenHash; + + return $this; + } + + public function getGrantId(): string + { + return $this->grantId; + } + + public function setGrantId(string $grantId): self + { + $this->grantId = $grantId; + + return $this; + } + + public function getClient(): OAuthClient + { + return $this->client; + } + + public function setClient(OAuthClient $client): self + { + $this->client = $client; + + return $this; + } + + public function getUser(): User + { + return $this->user; + } + + public function setUser(User $user): self + { + $this->user = $user; + + return $this; + } + + public function getAccessUrlId(): ?int + { + return $this->accessUrlId; + } + + public function setAccessUrlId(?int $accessUrlId): self + { + $this->accessUrlId = $accessUrlId; + + return $this; + } + + public function getScope(): ?string + { + return $this->scope; + } + + public function setScope(?string $scope): self + { + $this->scope = $scope; + + return $this; + } + + public function getResource(): ?string + { + return $this->resource; + } + + public function setResource(?string $resource): self + { + $this->resource = $resource; + + return $this; + } + + public function getConsentedAt(): DateTime + { + return $this->consentedAt; + } + + public function setConsentedAt(DateTime $consentedAt): self + { + $this->consentedAt = $consentedAt; + + return $this; + } + + public function getConsentIp(): ?string + { + return $this->consentIp; + } + + public function setConsentIp(?string $consentIp): self + { + $this->consentIp = $consentIp; + + return $this; + } + + public function getConsentUserAgent(): ?string + { + return $this->consentUserAgent; + } + + public function setConsentUserAgent(?string $consentUserAgent): self + { + $this->consentUserAgent = $consentUserAgent; + + return $this; + } + + public function getCreatedAt(): DateTime + { + return $this->createdAt; + } + + public function setCreatedAt(DateTime $createdAt): self + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getExpiresAt(): DateTime + { + return $this->expiresAt; + } + + public function setExpiresAt(DateTime $expiresAt): self + { + $this->expiresAt = $expiresAt; + + return $this; + } + + public function getAbsoluteExpiresAt(): DateTime + { + return $this->absoluteExpiresAt; + } + + public function setAbsoluteExpiresAt(DateTime $absoluteExpiresAt): self + { + $this->absoluteExpiresAt = $absoluteExpiresAt; + + return $this; + } + + public function getRotatedAt(): ?DateTime + { + return $this->rotatedAt; + } + + public function setRotatedAt(?DateTime $rotatedAt): self + { + $this->rotatedAt = $rotatedAt; + + return $this; + } + + public function getReplacedBy(): ?self + { + return $this->replacedBy; + } + + public function setReplacedBy(?self $replacedBy): self + { + $this->replacedBy = $replacedBy; + + return $this; + } + + public function getRevokedAt(): ?DateTime + { + return $this->revokedAt; + } + + public function setRevokedAt(?DateTime $revokedAt): self + { + $this->revokedAt = $revokedAt; + + return $this; + } + + public function getRevokedReason(): ?string + { + return $this->revokedReason; + } + + public function setRevokedReason(?string $revokedReason): self + { + $this->revokedReason = $revokedReason; + + return $this; + } + + public function getLastUsedAt(): ?DateTime + { + return $this->lastUsedAt; + } + + public function setLastUsedAt(?DateTime $lastUsedAt): self + { + $this->lastUsedAt = $lastUsedAt; + + return $this; + } + + public function isActiveAt(DateTime $date): bool + { + return null === $this->revokedAt + && null === $this->rotatedAt + && $this->expiresAt > $date + && $this->absoluteExpiresAt > $date; + } +} diff --git a/src/CoreBundle/Exception/OAuthServer/OAuthException.php b/src/CoreBundle/Exception/OAuthServer/OAuthException.php new file mode 100644 index 00000000000..8bf2db1bb03 --- /dev/null +++ b/src/CoreBundle/Exception/OAuthServer/OAuthException.php @@ -0,0 +1,105 @@ + $extraHeaders + */ + private function __construct( + private readonly string $errorCode, + string $errorDescription, + private readonly int $httpStatusCode, + private readonly array $extraHeaders = [], + ) { + parent::__construct($errorDescription); + } + + public function getErrorCode(): string + { + return $this->errorCode; + } + + public function getHttpStatusCode(): int + { + return $this->httpStatusCode; + } + + /** + * @return array + */ + public function getExtraHeaders(): array + { + return $this->extraHeaders; + } + + public static function invalidClientMetadata(string $description): self + { + return new self('invalid_client_metadata', $description, 400); + } + + public static function invalidRedirectUri(string $description): self + { + return new self('invalid_redirect_uri', $description, 400); + } + + public static function invalidRequest(string $description): self + { + return new self('invalid_request', $description, 400); + } + + public static function invalidGrant(string $description = 'The provided authorization grant is invalid.'): self + { + return new self('invalid_grant', $description, 400); + } + + public static function invalidTarget(string $description = 'The requested resource is invalid.'): self + { + return new self('invalid_target', $description, 400); + } + + /** + * @param array $extraHeaders + */ + public static function invalidClient( + string $description = 'Client authentication failed.', + array $extraHeaders = [], + ): self { + return new self('invalid_client', $description, 401, $extraHeaders); + } + + public static function unsupportedGrantType(): self + { + return new self('unsupported_grant_type', 'The requested grant type is not supported.', 400); + } + + public static function unsupportedResponseType(): self + { + return new self('unsupported_response_type', 'Only the "code" response type is supported.', 400); + } + + public static function accessDenied(): self + { + return new self('access_denied', 'The resource owner denied the request.', 400); + } +} diff --git a/src/CoreBundle/Migrations/Schema/V300/Version20260728120000.php b/src/CoreBundle/Migrations/Schema/V300/Version20260728120000.php new file mode 100644 index 00000000000..f3c0c487b44 --- /dev/null +++ b/src/CoreBundle/Migrations/Schema/V300/Version20260728120000.php @@ -0,0 +1,188 @@ +hasTable('oauth_client')) { + $this->addSql(<<<'SQL' + CREATE TABLE oauth_client ( + id INT AUTO_INCREMENT NOT NULL, + client_id VARCHAR(64) NOT NULL, + client_secret_hash VARCHAR(64) DEFAULT NULL, + client_secret_prefix VARCHAR(32) DEFAULT NULL, + token_endpoint_auth_method VARCHAR(32) NOT NULL, + client_name VARCHAR(255) DEFAULT NULL, + client_uri LONGTEXT DEFAULT NULL, + logo_uri LONGTEXT DEFAULT NULL, + policy_uri LONGTEXT DEFAULT NULL, + tos_uri LONGTEXT DEFAULT NULL, + software_id VARCHAR(255) DEFAULT NULL, + software_version VARCHAR(64) DEFAULT NULL, + redirect_uris JSON NOT NULL COMMENT '(DC2Type:json)', + grant_types JSON NOT NULL COMMENT '(DC2Type:json)', + response_types JSON NOT NULL COMMENT '(DC2Type:json)', + scope VARCHAR(255) DEFAULT NULL, + access_url_id INT DEFAULT NULL, + registration_ip VARCHAR(45) DEFAULT NULL, + created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime)', + last_used_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime)', + revoked_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime)', + UNIQUE INDEX uniq_oauth_client_client_id (client_id), + INDEX idx_oauth_client_url (access_url_id, revoked_at), + INDEX idx_oauth_client_created (created_at), + PRIMARY KEY(id) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB ROW_FORMAT = DYNAMIC + SQL); + + $this->addSql('ALTER TABLE oauth_client ADD CONSTRAINT FK_OAUTH_CLIENT_ACCESS_URL FOREIGN KEY (access_url_id) REFERENCES access_url (id) ON DELETE CASCADE'); + } + + if (!$schema->hasTable('oauth_authorization_code')) { + $this->addSql(<<<'SQL' + CREATE TABLE oauth_authorization_code ( + id INT AUTO_INCREMENT NOT NULL, + code_hash VARCHAR(64) NOT NULL, + grant_id VARCHAR(36) NOT NULL, + client_id INT NOT NULL, + user_id INT NOT NULL, + access_url_id INT DEFAULT NULL, + redirect_uri LONGTEXT NOT NULL, + code_challenge VARCHAR(128) NOT NULL, + code_challenge_method VARCHAR(10) NOT NULL, + scope VARCHAR(255) DEFAULT NULL, + resource LONGTEXT DEFAULT NULL, + consent_ip VARCHAR(45) DEFAULT NULL, + consent_user_agent VARCHAR(255) DEFAULT NULL, + created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime)', + expires_at DATETIME NOT NULL COMMENT '(DC2Type:datetime)', + used_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime)', + UNIQUE INDEX uniq_oauth_code_hash (code_hash), + INDEX idx_oauth_code_grant (grant_id), + INDEX idx_oauth_code_expires (expires_at), + INDEX idx_oauth_code_user (user_id), + INDEX idx_oauth_code_client (client_id), + PRIMARY KEY(id) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB ROW_FORMAT = DYNAMIC + SQL); + + $this->addSql('ALTER TABLE oauth_authorization_code ADD CONSTRAINT FK_OAUTH_CODE_CLIENT FOREIGN KEY (client_id) REFERENCES oauth_client (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE oauth_authorization_code ADD CONSTRAINT FK_OAUTH_CODE_USER FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE oauth_authorization_code ADD CONSTRAINT FK_OAUTH_CODE_ACCESS_URL FOREIGN KEY (access_url_id) REFERENCES access_url (id) ON DELETE CASCADE'); + } + + if (!$schema->hasTable('oauth_access_token')) { + $this->addSql(<<<'SQL' + CREATE TABLE oauth_access_token ( + id INT AUTO_INCREMENT NOT NULL, + token_hash VARCHAR(64) NOT NULL, + token_prefix VARCHAR(32) DEFAULT NULL, + grant_id VARCHAR(36) NOT NULL, + client_id INT NOT NULL, + user_id INT NOT NULL, + access_url_id INT DEFAULT NULL, + scope VARCHAR(255) DEFAULT NULL, + resource LONGTEXT DEFAULT NULL, + created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime)', + expires_at DATETIME NOT NULL COMMENT '(DC2Type:datetime)', + last_used_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime)', + revoked_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime)', + UNIQUE INDEX uniq_oauth_access_hash (token_hash), + INDEX idx_oauth_access_grant (grant_id), + INDEX idx_oauth_access_user (user_id, revoked_at), + INDEX idx_oauth_access_expires (expires_at), + INDEX idx_oauth_access_client (client_id), + PRIMARY KEY(id) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB ROW_FORMAT = DYNAMIC + SQL); + + $this->addSql('ALTER TABLE oauth_access_token ADD CONSTRAINT FK_OAUTH_ACCESS_CLIENT FOREIGN KEY (client_id) REFERENCES oauth_client (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE oauth_access_token ADD CONSTRAINT FK_OAUTH_ACCESS_USER FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE oauth_access_token ADD CONSTRAINT FK_OAUTH_ACCESS_ACCESS_URL FOREIGN KEY (access_url_id) REFERENCES access_url (id) ON DELETE CASCADE'); + } + + if (!$schema->hasTable('oauth_refresh_token')) { + $this->addSql(<<<'SQL' + CREATE TABLE oauth_refresh_token ( + id INT AUTO_INCREMENT NOT NULL, + token_hash VARCHAR(64) NOT NULL, + grant_id VARCHAR(36) NOT NULL, + client_id INT NOT NULL, + user_id INT NOT NULL, + access_url_id INT DEFAULT NULL, + scope VARCHAR(255) DEFAULT NULL, + resource LONGTEXT DEFAULT NULL, + consented_at DATETIME NOT NULL COMMENT '(DC2Type:datetime)', + consent_ip VARCHAR(45) DEFAULT NULL, + consent_user_agent VARCHAR(255) DEFAULT NULL, + created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime)', + expires_at DATETIME NOT NULL COMMENT '(DC2Type:datetime)', + absolute_expires_at DATETIME NOT NULL COMMENT '(DC2Type:datetime)', + rotated_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime)', + replaced_by_id INT DEFAULT NULL, + revoked_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime)', + revoked_reason VARCHAR(32) DEFAULT NULL, + last_used_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime)', + UNIQUE INDEX uniq_oauth_refresh_hash (token_hash), + INDEX idx_oauth_refresh_grant (grant_id), + INDEX idx_oauth_refresh_user (user_id, revoked_at, rotated_at), + INDEX idx_oauth_refresh_expires (expires_at), + INDEX idx_oauth_refresh_client (client_id), + INDEX idx_oauth_refresh_replaced_by (replaced_by_id), + PRIMARY KEY(id) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB ROW_FORMAT = DYNAMIC + SQL); + + $this->addSql('ALTER TABLE oauth_refresh_token ADD CONSTRAINT FK_OAUTH_REFRESH_CLIENT FOREIGN KEY (client_id) REFERENCES oauth_client (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE oauth_refresh_token ADD CONSTRAINT FK_OAUTH_REFRESH_USER FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE oauth_refresh_token ADD CONSTRAINT FK_OAUTH_REFRESH_ACCESS_URL FOREIGN KEY (access_url_id) REFERENCES access_url (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE oauth_refresh_token ADD CONSTRAINT FK_OAUTH_REFRESH_REPLACED_BY FOREIGN KEY (replaced_by_id) REFERENCES oauth_refresh_token (id) ON DELETE SET NULL'); + } + } + + public function down(Schema $schema): void + { + // Drop in reverse FK order: tables referencing oauth_client first, then oauth_client itself. + if ($schema->hasTable('oauth_access_token')) { + $this->addSql('DROP TABLE oauth_access_token'); + } + + if ($schema->hasTable('oauth_authorization_code')) { + $this->addSql('DROP TABLE oauth_authorization_code'); + } + + if ($schema->hasTable('oauth_refresh_token')) { + $this->addSql('DROP TABLE oauth_refresh_token'); + } + + if ($schema->hasTable('oauth_client')) { + $this->addSql('DROP TABLE oauth_client'); + } + } +} diff --git a/src/CoreBundle/Migrations/Schema/V300/Version20260728130000.php b/src/CoreBundle/Migrations/Schema/V300/Version20260728130000.php new file mode 100644 index 00000000000..abcafc6bc00 --- /dev/null +++ b/src/CoreBundle/Migrations/Schema/V300/Version20260728130000.php @@ -0,0 +1,310 @@ +collectFromFixtures($rows, (array) SettingsCurrentFixtures::getExistingSettings()); + $this->collectFromFixtures($rows, (array) SettingsCurrentFixtures::getNewConfigurationSettings()); + + // Index by variable (last occurrence wins) + $byVariable = []; + foreach ($rows as $r) { + $byVariable[$r['variable']] = $r; + } + + // Defaults from SettingsManager schemas + $defaults = $this->buildDefaultsFromSchemas(); + + foreach ($byVariable as $variable => $row) { + if (!$this->isValidIdentifier($variable)) { + error_log(\sprintf('[SKIP] Invalid variable name: "%s".', $variable)); + + continue; + } + $category = $row['category']; + if (!$this->isValidIdentifier($category)) { + error_log(\sprintf('[SKIP] Invalid category for "%s": "%s".', $variable, $category)); + + continue; + } + + $title = (string) $row['title']; + $comment = (string) $row['comment']; + + try { + $exists = (int) $this->connection->fetchOne( + 'SELECT COUNT(*) FROM settings WHERE variable = ?', + [$variable] + ); + } catch (Throwable $e) { + error_log(\sprintf('[ERROR] Existence check failed for "%s": %s', $variable, $e->getMessage())); + + continue; + } + + if ($exists > 0) { + $sql = 'UPDATE settings SET category = ?, title = ?, comment = ? WHERE variable = ?'; + $params = [$category, $title, $comment, $variable]; + $this->addSql($sql, $params); + } else { + $defaultValue = $defaults[$variable] ?? ''; + $sql = 'INSERT INTO settings (variable, category, title, comment, selected_value, access_url_changeable) VALUES (?, ?, ?, ?, ?, 0)'; + $params = [$variable, $category, $title, $comment, (string) $defaultValue]; + $this->addSql($sql, $params); + } + } + + $this->dryRunTemplatesUpsertAndLink(); + } + + public function down(Schema $schema): void + { + error_log('[MIGRATION] Down is a no-op (dry-run migration).'); + } + + /** + * Read fixtures and normalize to: [variable, category, title, comment]. + */ + private function collectFromFixtures(array &$out, array $byCategory): void + { + foreach ($byCategory as $categoryKey => $settings) { + $category = strtolower((string) $categoryKey); + + foreach ((array) $settings as $setting) { + $variable = (string) ($setting['name'] ?? $setting['variable'] ?? ''); + if ('' === $variable) { + error_log(\sprintf('[WARN] Missing "name" in fixture entry for category "%s" - skipping.', $category)); + + continue; + } + + $title = (string) ($setting['title'] ?? $variable); + $comment = (string) ($setting['comment'] ?? ''); + + $out[] = [ + 'variable' => $variable, + 'category' => $category, + 'title' => $title, + 'comment' => $comment, + ]; + } + } + } + + /** + * Build default values map by scanning SettingsManager schemas. + * Returns: [ variable => defaultValueAsString, ... ]. + */ + private function buildDefaultsFromSchemas(): array + { + $map = []; + + $manager = $this->container->get(SettingsManager::class); + if (!$manager) { + error_log('[WARN] SettingsManager not found; defaults map will be empty.'); + + return $map; + } + + try { + $schemas = $manager->getSchemas(); + } catch (Throwable $e) { + error_log('[WARN] getSchemas() failed on SettingsManager: '.$e->getMessage()); + + return $map; + } + + foreach (array_keys($schemas) as $serviceIdOrAlias) { + $namespace = str_replace('chamilo_core.settings.', '', (string) $serviceIdOrAlias); + + try { + $settingsBag = $manager->load($namespace); + } catch (Throwable $e) { + error_log(\sprintf('[WARN] load("%s") failed: %s', $namespace, $e->getMessage())); + + continue; + } + + $parameters = []; + + try { + if (method_exists($settingsBag, 'getParameters')) { + $parameters = (array) $settingsBag->getParameters(); + } elseif (method_exists($settingsBag, 'all')) { + $parameters = (array) $settingsBag->all(); + } elseif (method_exists($settingsBag, 'toArray')) { + $parameters = (array) $settingsBag->toArray(); + } + } catch (Throwable $e) { + error_log(\sprintf('[WARN] Could not extract parameters for "%s": %s', $namespace, $e->getMessage())); + $parameters = []; + } + + if (empty($parameters)) { + try { + $keys = []; + if (method_exists($settingsBag, 'keys')) { + $keys = (array) $settingsBag->keys(); + } elseif (method_exists($settingsBag, 'getIterator')) { + $keys = array_keys(iterator_to_array($settingsBag->getIterator())); + } + foreach ($keys as $k) { + try { + $parameters[$k] = $settingsBag->get($k); + } catch (Throwable $e) { + // ignore + } + } + } catch (Throwable $e) { + error_log(\sprintf('[WARN] Parameter keys iteration failed for "%s": %s', $namespace, $e->getMessage())); + } + } + + foreach ($parameters as $var => $val) { + $var = (string) $var; + if ('' === $var) { + continue; + } + if (!\array_key_exists($var, $map)) { + $map[$var] = \is_scalar($val) ? (string) $val : (string) json_encode($val); + } + } + } + + return $map; + } + + /** + * Upsert settings_value_template and preview linking to settings.value_template_id. + * For safety, we: + * - validate variable + * - JSON-encode examples with proper flags + * - do existence checks with SELECTs. + */ + private function dryRunTemplatesUpsertAndLink(): void + { + $grouped = []; + + try { + $grouped = (array) SettingsValueTemplateFixtures::getTemplatesGrouped(); + } catch (Throwable $e) { + error_log('[WARN] Unable to load SettingsValueTemplateFixtures::getTemplatesGrouped(): '.$e->getMessage()); + + return; + } + + foreach ($grouped as $category => $items) { + foreach ((array) $items as $setting) { + $variable = (string) ($setting['variable'] ?? $setting['name'] ?? ''); + $jsonExample = $setting['json_example'] ?? null; + + if ('' === $variable || !$this->isValidIdentifier($variable)) { + error_log(\sprintf('[SKIP] Invalid or empty template variable in category "%s".', (string) $category)); + + continue; + } + + // Serialize JSON example safely (string for SQL param preview) + try { + $jsonEncoded = json_encode($jsonExample, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } catch (Throwable $e) { + error_log(\sprintf('[WARN] JSON encoding failed for variable "%s": %s', $variable, $e->getMessage())); + $jsonEncoded = 'null'; + } + + // Check if template exists + try { + $templateId = $this->connection->fetchOne( + 'SELECT id FROM settings_value_template WHERE variable = ?', + [$variable] + ); + } catch (Throwable $e) { + error_log(\sprintf('[ERROR] Failed to check template existence for "%s": %s', $variable, $e->getMessage())); + + continue; + } + + if ($templateId) { + // UPDATE PREVIEW on existing template + $sql = 'UPDATE settings_value_template SET json_example = ?, updated_at = NOW() WHERE id = ?'; + $params = [$jsonEncoded, $templateId]; + $this->addSql($sql, $params); + } else { + // INSERT PREVIEW new template + $sql = 'INSERT INTO settings_value_template (variable, json_example, created_at, updated_at) VALUES (?, ?, NOW(), NOW())'; + $params = [$variable, $jsonEncoded]; + $this->addSql($sql, $params); + + // Try to discover what ID it would be (optional best-effort) + try { + // We DO NOT insert, so we cannot call lastInsertId(). + // Instead, try to SELECT id if it exists already after a previous run; otherwise log NULL. + $templateId = $this->connection->fetchOne( + 'SELECT id FROM settings_value_template WHERE variable = ?', + [$variable] + ); + } catch (Throwable $e) { + $templateId = false; + } + } + + // Link PREVIEW: settings.value_template_id = $templateId + if ($templateId) { + $sql = 'UPDATE settings SET value_template_id = ? WHERE variable = ?'; + $params = [$templateId, $variable]; + $this->addSql($sql, $params); + } else { + error_log(\sprintf('[INFO] Skipping link preview for "%s" (no template id available in dry-run).', $variable)); + } + } + } + } + + /** + * Allow letters, numbers, underscore, dash and dot. + */ + private function isValidIdentifier(string $s): bool + { + return (bool) preg_match('/^[A-Za-z0-9_.-]+$/', $s); + } +} diff --git a/src/CoreBundle/Repository/OAuthAccessTokenRepository.php b/src/CoreBundle/Repository/OAuthAccessTokenRepository.php new file mode 100644 index 00000000000..24491b76cf8 --- /dev/null +++ b/src/CoreBundle/Repository/OAuthAccessTokenRepository.php @@ -0,0 +1,100 @@ + + */ +final class OAuthAccessTokenRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, OAuthAccessToken::class); + } + + public function findActiveByHash(string $hash, DateTime $now): ?OAuthAccessToken + { + return $this->createQueryBuilder('token') + ->andWhere('token.tokenHash = :hash') + ->andWhere('token.revokedAt IS NULL') + ->andWhere('token.expiresAt > :now') + ->setParameter('hash', $hash) + ->setParameter('now', $now) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult() + ; + } + + public function touchLastUsed(OAuthAccessToken $token, DateTime $now): void + { + $lastUsedAt = $token->getLastUsedAt(); + if (null !== $lastUsedAt && $lastUsedAt->getTimestamp() > $now->getTimestamp() - 300) { + return; + } + + $this->createQueryBuilder('token') + ->update() + ->set('token.lastUsedAt', ':now') + ->andWhere('token.id = :id') + ->setParameter('now', $now) + ->setParameter('id', $token->getId()) + ->getQuery() + ->execute() + ; + + $token->setLastUsedAt($now); + } + + public function revokeByGrantId(string $grantId, DateTime $now): int + { + return (int) $this->createQueryBuilder('token') + ->update() + ->set('token.revokedAt', ':now') + ->andWhere('token.grantId = :grantId') + ->andWhere('token.revokedAt IS NULL') + ->setParameter('now', $now) + ->setParameter('grantId', $grantId) + ->getQuery() + ->execute() + ; + } + + /** + * @return array + */ + public function findActiveForUserAndAccessUrl(int $userId, int $accessUrlId, DateTime $now): array + { + return $this->createQueryBuilder('token') + ->andWhere('token.user = :userId') + ->andWhere('token.accessUrlId = :accessUrlId') + ->andWhere('token.revokedAt IS NULL') + ->andWhere('token.expiresAt > :now') + ->setParameter('userId', $userId) + ->setParameter('accessUrlId', $accessUrlId) + ->setParameter('now', $now) + ->getQuery() + ->getResult() + ; + } + + public function deleteExpired(DateTime $before): int + { + return (int) $this->createQueryBuilder('token') + ->delete() + ->andWhere('token.expiresAt < :before') + ->setParameter('before', $before) + ->getQuery() + ->execute() + ; + } +} diff --git a/src/CoreBundle/Repository/OAuthAuthorizationCodeRepository.php b/src/CoreBundle/Repository/OAuthAuthorizationCodeRepository.php new file mode 100644 index 00000000000..22a3f2c60c1 --- /dev/null +++ b/src/CoreBundle/Repository/OAuthAuthorizationCodeRepository.php @@ -0,0 +1,66 @@ + + */ +final class OAuthAuthorizationCodeRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, OAuthAuthorizationCode::class); + } + + public function findOneByHash(string $hash): ?OAuthAuthorizationCode + { + return $this->createQueryBuilder('code') + ->andWhere('code.codeHash = :hash') + ->setParameter('hash', $hash) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult() + ; + } + + /** + * Atomically marks the code as used. Returns false if it was already used + * (or does not exist) — a conditional UPDATE, not read-then-write, so two + * concurrent /token calls against the same code cannot both succeed. + */ + public function consumeAtomically(int $id, DateTime $now): bool + { + $affected = $this->createQueryBuilder('code') + ->update() + ->set('code.usedAt', ':now') + ->andWhere('code.id = :id') + ->andWhere('code.usedAt IS NULL') + ->setParameter('now', $now) + ->setParameter('id', $id) + ->getQuery() + ->execute() + ; + + return 1 === $affected; + } + + public function deleteExpired(DateTime $before): int + { + return (int) $this->createQueryBuilder('code') + ->delete() + ->andWhere('code.expiresAt < :before') + ->setParameter('before', $before) + ->getQuery() + ->execute() + ; + } +} diff --git a/src/CoreBundle/Repository/OAuthClientRepository.php b/src/CoreBundle/Repository/OAuthClientRepository.php new file mode 100644 index 00000000000..39498641ae6 --- /dev/null +++ b/src/CoreBundle/Repository/OAuthClientRepository.php @@ -0,0 +1,84 @@ + + */ +final class OAuthClientRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, OAuthClient::class); + } + + public function findActiveByClientIdAndAccessUrl(string $clientId, int $accessUrlId): ?OAuthClient + { + return $this->createQueryBuilder('client') + ->andWhere('client.clientId = :clientId') + ->andWhere('client.accessUrlId = :accessUrlId') + ->andWhere('client.revokedAt IS NULL') + ->setParameter('clientId', $clientId) + ->setParameter('accessUrlId', $accessUrlId) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult() + ; + } + + public function countRecentRegistrationsByIp(string $ip, DateTime $since): int + { + return (int) $this->createQueryBuilder('client') + ->select('COUNT(client.id)') + ->andWhere('client.registrationIp = :ip') + ->andWhere('client.createdAt > :since') + ->setParameter('ip', $ip) + ->setParameter('since', $since) + ->getQuery() + ->getSingleScalarResult() + ; + } + + public function touchLastUsed(OAuthClient $client, DateTime $now): void + { + $lastUsedAt = $client->getLastUsedAt(); + if (null !== $lastUsedAt && $lastUsedAt->getTimestamp() > $now->getTimestamp() - 300) { + return; + } + + $this->createQueryBuilder('client') + ->update() + ->set('client.lastUsedAt', ':now') + ->andWhere('client.id = :id') + ->setParameter('now', $now) + ->setParameter('id', $client->getId()) + ->getQuery() + ->execute() + ; + + $client->setLastUsedAt($now); + } + + /** + * @return array + */ + public function findStaleUnusedClients(DateTime $before): array + { + return $this->createQueryBuilder('client') + ->andWhere('client.createdAt < :before') + ->andWhere('client.lastUsedAt IS NULL') + ->setParameter('before', $before) + ->getQuery() + ->getResult() + ; + } +} diff --git a/src/CoreBundle/Repository/OAuthRefreshTokenRepository.php b/src/CoreBundle/Repository/OAuthRefreshTokenRepository.php new file mode 100644 index 00000000000..bc79b9a901e --- /dev/null +++ b/src/CoreBundle/Repository/OAuthRefreshTokenRepository.php @@ -0,0 +1,136 @@ + + */ +final class OAuthRefreshTokenRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, OAuthRefreshToken::class); + } + + public function findOneByHash(string $hash): ?OAuthRefreshToken + { + return $this->createQueryBuilder('token') + ->andWhere('token.tokenHash = :hash') + ->setParameter('hash', $hash) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult() + ; + } + + /** + * Atomically marks this generation as rotated. Returns false if it was + * already rotated or revoked (or does not exist) — a conditional UPDATE, + * not read-then-write, so a race between two refresh attempts against the + * same token is detectable as reuse rather than silently both succeeding. + */ + public function rotateAtomically(int $id, DateTime $now): bool + { + $affected = $this->createQueryBuilder('token') + ->update() + ->set('token.rotatedAt', ':now') + ->andWhere('token.id = :id') + ->andWhere('token.rotatedAt IS NULL') + ->andWhere('token.revokedAt IS NULL') + ->setParameter('now', $now) + ->setParameter('id', $id) + ->getQuery() + ->execute() + ; + + return 1 === $affected; + } + + public function revokeFamily(string $grantId, string $reason, DateTime $now): int + { + return (int) $this->createQueryBuilder('token') + ->update() + ->set('token.revokedAt', ':now') + ->set('token.revokedReason', ':reason') + ->andWhere('token.grantId = :grantId') + ->andWhere('token.revokedAt IS NULL') + ->setParameter('now', $now) + ->setParameter('reason', $reason) + ->setParameter('grantId', $grantId) + ->getQuery() + ->execute() + ; + } + + /** + * The "Connected apps" query: one live (non-rotated, non-revoked, + * non-expired) refresh-token row per grant IS the grant record. + * + * @return array + */ + public function findActiveGrantsForUserAndAccessUrl(int $userId, int $accessUrlId, DateTime $now): array + { + return $this->createQueryBuilder('token') + ->andWhere('token.user = :userId') + ->andWhere('token.accessUrlId = :accessUrlId') + ->andWhere('token.revokedAt IS NULL') + ->andWhere('token.rotatedAt IS NULL') + ->andWhere('token.expiresAt > :now') + ->andWhere('token.absoluteExpiresAt > :now') + ->setParameter('userId', $userId) + ->setParameter('accessUrlId', $accessUrlId) + ->setParameter('now', $now) + ->orderBy('token.consentedAt', 'DESC') + ->getQuery() + ->getResult() + ; + } + + /** + * Ownership-scoped lookup for the revoke endpoint — never look up a grant + * by id alone, always scoped to the requesting user and portal. + */ + public function findActiveGrantByIdForUser( + string $grantId, + int $userId, + int $accessUrlId, + DateTime $now, + ): ?OAuthRefreshToken { + return $this->createQueryBuilder('token') + ->andWhere('token.grantId = :grantId') + ->andWhere('token.user = :userId') + ->andWhere('token.accessUrlId = :accessUrlId') + ->andWhere('token.revokedAt IS NULL') + ->andWhere('token.rotatedAt IS NULL') + ->andWhere('token.expiresAt > :now') + ->andWhere('token.absoluteExpiresAt > :now') + ->setParameter('grantId', $grantId) + ->setParameter('userId', $userId) + ->setParameter('accessUrlId', $accessUrlId) + ->setParameter('now', $now) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult() + ; + } + + public function deleteExpired(DateTime $before): int + { + return (int) $this->createQueryBuilder('token') + ->delete() + ->andWhere('token.absoluteExpiresAt < :before') + ->setParameter('before', $before) + ->getQuery() + ->execute() + ; + } +} diff --git a/src/CoreBundle/Resources/views/OAuthServer/consent.html.twig b/src/CoreBundle/Resources/views/OAuthServer/consent.html.twig new file mode 100644 index 00000000000..ff2aaa89315 --- /dev/null +++ b/src/CoreBundle/Resources/views/OAuthServer/consent.html.twig @@ -0,0 +1,51 @@ +{% extends "@ChamiloCore/Layout/layout_one_col.html.twig" %} + +{% block content %} +
+
+

+ {{ 'Authorize application'|trans }} +

+ +

+ {{ client.clientName|default('An application') }} + {{ 'wants to connect to your Chamilo account.'|trans }} +

+ +
+

+ {{ 'Signed in as'|trans }} +

+

+ {{ oauth_user.fullName|default(oauth_user.username) }} ({{ oauth_user.username }}) +

+
+ +

+ {{ 'This application will be able to act on Chamilo exactly as you can — using your existing account permissions and nothing more. It will never gain any access you do not already have.'|trans }} +

+ +
+ + + + + +
+
+
+{% endblock %} diff --git a/src/CoreBundle/Resources/views/OAuthServer/error.html.twig b/src/CoreBundle/Resources/views/OAuthServer/error.html.twig new file mode 100644 index 00000000000..28372b4d19e --- /dev/null +++ b/src/CoreBundle/Resources/views/OAuthServer/error.html.twig @@ -0,0 +1,15 @@ +{% extends "@ChamiloCore/Layout/layout_one_col.html.twig" %} + +{% block content %} +
+
+

+ {{ 'An error occurred'|trans }} +

+ +

+ {{ message }} +

+
+
+{% endblock %} diff --git a/src/CoreBundle/Security/Authenticator/McpBearerAuthenticator.php b/src/CoreBundle/Security/Authenticator/McpBearerAuthenticator.php index 2317d7d2141..647367b464d 100644 --- a/src/CoreBundle/Security/Authenticator/McpBearerAuthenticator.php +++ b/src/CoreBundle/Security/Authenticator/McpBearerAuthenticator.php @@ -7,13 +7,17 @@ namespace Chamilo\CoreBundle\Security\Authenticator; use Chamilo\CoreBundle\Entity\AccessUrl; +use Chamilo\CoreBundle\Entity\OAuthAccessToken; use Chamilo\CoreBundle\Entity\User; use Chamilo\CoreBundle\Entity\UserApiKey; use Chamilo\CoreBundle\Helpers\AccessUrlHelper; use Chamilo\CoreBundle\Repository\Node\AccessUrlRepository; use Chamilo\CoreBundle\Repository\Node\UserRepository; +use Chamilo\CoreBundle\Repository\OAuthAccessTokenRepository; use Chamilo\CoreBundle\Repository\UserApiKeyRepository; use Chamilo\CoreBundle\Service\Mcp\McpApiKeyManager; +use Chamilo\CoreBundle\Service\OAuthServer\OAuthMetadataService; +use Chamilo\CoreBundle\Service\OAuthServer\OAuthTokenService; use DateTime; use Exception; use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface; @@ -39,6 +43,8 @@ public function __construct( private readonly AccessUrlRepository $accessUrlRepository, private readonly JWTTokenManagerInterface $jwtManager, private readonly RateLimiterFactory $mcpAuthenticationLimiter, + private readonly OAuthMetadataService $oauthMetadata, + private readonly OAuthAccessTokenRepository $oauthAccessTokenRepository, ) {} public function supports(Request $request): ?bool @@ -60,6 +66,10 @@ public function authenticate(Request $request): Passport throw new CustomUserMessageAuthenticationException('Missing or invalid MCP bearer credential.'); } + if (OAuthTokenService::isAccessToken($bearer)) { + return $this->authenticateOAuthToken($request, $bearer); + } + if (McpApiKeyManager::isMcpKey($bearer)) { return $this->authenticateApiKey($request, $bearer); } @@ -74,18 +84,51 @@ public function onAuthenticationSuccess(Request $request, TokenInterface $token, public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response { - return new JsonResponse( + $response = new JsonResponse( ['message' => $exception->getMessageKey()], Response::HTTP_UNAUTHORIZED, ); + $response->headers->set( + 'WWW-Authenticate', + $this->buildWwwAuthenticateHeader('invalid_token', $exception->getMessageKey()), + ); + + return $response; } public function start(Request $request, ?AuthenticationException $authException = null): Response { - return new JsonResponse( + $response = new JsonResponse( ['message' => 'MCP authentication credentials are required.'], Response::HTTP_UNAUTHORIZED, ); + $response->headers->set('WWW-Authenticate', $this->buildWwwAuthenticateHeader()); + + return $response; + } + + /** + * Advertises where an OAuth client can discover this resource server's + * Authorization Server (RFC 9728 §5.1 / MCP authorization spec). Without + * this header, a bare 401 gives an OAuth client nothing to discover the + * new /oauth/* endpoints with. + */ + private function buildWwwAuthenticateHeader(?string $error = null, ?string $errorDescription = null): string + { + $parts = [ + 'realm="Chamilo MCP"', + 'resource_metadata="'.$this->oauthMetadata->getResourceMetadataUrl('mcp').'"', + ]; + + if (null !== $error) { + $parts[] = 'error="'.$error.'"'; + } + + if (null !== $errorDescription) { + $parts[] = 'error_description="'.str_replace('"', "'", $errorDescription).'"'; + } + + return 'Bearer '.implode(', ', $parts); } private function authenticateApiKey(Request $request, string $plainKey): SelfValidatingPassport @@ -115,6 +158,46 @@ private function authenticateApiKey(Request $request, string $plainKey): SelfVal ); } + /** + * Accepts an access token issued by the generic OAuth Authorization + * Server (see Chamilo\CoreBundle\Service\OAuthServer\*) — the first + * resource server built on top of it. isAccessToken()'s prefix regex is + * disjoint from McpApiKeyManager::isMcpKey()'s, so the two schemes never + * collide; keep this branch ordered before isMcpKey() regardless, so the + * precedence stays explicit rather than accidental. + */ + private function authenticateOAuthToken(Request $request, string $plainToken): SelfValidatingPassport + { + $accessUrl = $this->resolveAccessUrl(); + $now = new DateTime(); + $hash = hash('sha256', $plainToken); + $token = $this->oauthAccessTokenRepository->findActiveByHash($hash, $now); + + if (!$token instanceof OAuthAccessToken || !hash_equals($token->getTokenHash(), $hash)) { + throw new CustomUserMessageAuthenticationException('Invalid or revoked MCP OAuth access token.'); + } + + if ($token->getAccessUrlId() !== (int) $accessUrl->getId()) { + // Same message as an outright invalid token: a cross-portal + // token must not be distinguishable from a bogus one. + throw new CustomUserMessageAuthenticationException('Invalid or revoked MCP OAuth access token.'); + } + + if (!$token->getClient()->isActiveAt($now)) { + throw new CustomUserMessageAuthenticationException('Invalid or revoked MCP OAuth access token.'); + } + + $user = $token->getUser(); + $this->assertUserCanAuthenticate($user, $accessUrl); + $this->oauthAccessTokenRepository->touchLastUsed($token, $now); + + $request->attributes->set('_chamilo_mcp_auth_source', 'oauth'); + + return new SelfValidatingPassport( + new UserBadge((string) $user->getId(), static fn (): User => $user), + ); + } + private function authenticateJwt(Request $request, string $jwt): SelfValidatingPassport { try { diff --git a/src/CoreBundle/Service/OAuthServer/OAuthAuthorizationService.php b/src/CoreBundle/Service/OAuthServer/OAuthAuthorizationService.php new file mode 100644 index 00000000000..cc707e5d512 --- /dev/null +++ b/src/CoreBundle/Service/OAuthServer/OAuthAuthorizationService.php @@ -0,0 +1,184 @@ +query->get('client_id', ''); + $redirectUri = (string) $request->query->get('redirect_uri', ''); + + if ('' === $clientId || '' === $redirectUri) { + throw new RuntimeException('A client_id and redirect_uri are required.'); + } + + $client = $this->clientResolver->resolveActive($clientId); + if (!$client instanceof OAuthClient) { + throw new RuntimeException('Unknown or revoked OAuth client.'); + } + + if (!$client->supportsRedirectUri($redirectUri)) { + throw new RuntimeException('This redirect_uri is not registered for this client.'); + } + + return ['client' => $client, 'redirectUri' => $redirectUri]; + } + + /** + * Phase 2: everything else. Once the client/redirect_uri are trusted, + * failures here ARE reported by redirecting with ?error=...&state=.... + */ + public function validateAuthorizeParameters(Request $request): OAuthAuthorizeRequest + { + $state = (string) $request->query->get('state', ''); + + if ('code' !== (string) $request->query->get('response_type', '')) { + throw OAuthException::unsupportedResponseType(); + } + + $codeChallengeMethod = (string) $request->query->get('code_challenge_method', ''); + if ('S256' !== $codeChallengeMethod) { + throw OAuthException::invalidRequest('PKCE with code_challenge_method=S256 is required.'); + } + + $codeChallenge = (string) $request->query->get('code_challenge', ''); + if (1 !== preg_match('/^[A-Za-z0-9_-]{43}$/', $codeChallenge)) { + throw OAuthException::invalidRequest('A valid code_challenge is required.'); + } + + $resource = $request->query->get('resource'); + $resource = \is_string($resource) && '' !== $resource ? $resource : null; + + return new OAuthAuthorizeRequest( + clientId: (string) $request->query->get('client_id', ''), + redirectUri: (string) $request->query->get('redirect_uri', ''), + state: $state, + codeChallenge: $codeChallenge, + codeChallengeMethod: $codeChallengeMethod, + resource: $resource, + createdAt: time(), + ); + } + + public function stashInSession(Request $request, OAuthAuthorizeRequest $authorizeRequest): void + { + $request->getSession()->set(self::SESSION_KEY, $authorizeRequest); + } + + public function popFromSession(Request $request): ?OAuthAuthorizeRequest + { + $session = $request->getSession(); + $pending = $session->get(self::SESSION_KEY); + $session->remove(self::SESSION_KEY); + + return $pending instanceof OAuthAuthorizeRequest ? $pending : null; + } + + public function assertUserActiveOnCurrentPortal(User $user): bool + { + if (User::ACTIVE !== $user->getActive()) { + return false; + } + + $expirationDate = $user->getExpirationDate(); + if (null !== $expirationDate && $expirationDate <= new DateTime()) { + return false; + } + + $accessUrl = $this->accessUrlHelper->getCurrent(); + if (null === $accessUrl) { + return false; + } + + return $this->accessUrlRepository->isUrlActiveForUser($accessUrl, $user); + } + + public function issueCode( + OAuthClient $client, + OAuthAuthorizeRequest $authorizeRequest, + User $user, + Request $consentRequest, + ): string { + $accessUrl = $this->accessUrlHelper->getCurrent(); + $now = new DateTime(); + $plainCode = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); + + $code = (new OAuthAuthorizationCode()) + ->setCodeHash(hash('sha256', $plainCode)) + ->setGrantId(Uuid::v4()->toRfc4122()) + ->setClient($client) + ->setUser($user) + ->setAccessUrlId(null !== $accessUrl ? (int) $accessUrl->getId() : null) + ->setRedirectUri($authorizeRequest->redirectUri) + ->setCodeChallenge($authorizeRequest->codeChallenge) + ->setCodeChallengeMethod($authorizeRequest->codeChallengeMethod) + ->setScope(self::SCOPE) + ->setResource($authorizeRequest->resource) + // Captured now, at the real browser consent moment, so the + // eventual OAuthRefreshToken "connected app" record reflects who + // actually clicked Allow rather than whichever backend later + // exchanges the code at /token. + ->setConsentIp($consentRequest->getClientIp()) + ->setConsentUserAgent(mb_substr((string) $consentRequest->headers->get('User-Agent', ''), 0, 255)) + ->setCreatedAt($now) + ->setExpiresAt((clone $now)->modify('+'.self::CODE_TTL_SECONDS.' seconds')) + ; + + $this->entityManager->persist($code); + $this->entityManager->flush(); + + return $plainCode; + } + + /** + * @param array $params + */ + public function buildRedirectUrl(string $redirectUri, array $params): string + { + $separator = str_contains($redirectUri, '?') ? '&' : '?'; + + return $redirectUri.$separator.http_build_query($params); + } +} diff --git a/src/CoreBundle/Service/OAuthServer/OAuthClientRegistrar.php b/src/CoreBundle/Service/OAuthServer/OAuthClientRegistrar.php new file mode 100644 index 00000000000..688f3453ce9 --- /dev/null +++ b/src/CoreBundle/Service/OAuthServer/OAuthClientRegistrar.php @@ -0,0 +1,202 @@ + $metadata + * + * @return array + */ + public function register(array $metadata, string $clientIp): array + { + $redirectUris = $this->validateRedirectUris($metadata['redirect_uris'] ?? null); + $grantTypes = $this->validateSubset($metadata['grant_types'] ?? null, self::ALLOWED_GRANT_TYPES, self::ALLOWED_GRANT_TYPES); + $responseTypes = $this->validateSubset($metadata['response_types'] ?? null, self::ALLOWED_RESPONSE_TYPES, self::ALLOWED_RESPONSE_TYPES); + $authMethod = $this->validateAuthMethod($metadata['token_endpoint_auth_method'] ?? 'none'); + + $accessUrl = $this->accessUrlHelper->getCurrent(); + if (!$accessUrl instanceof AccessUrl || null === $accessUrl->getId()) { + throw new RuntimeException('The current access URL could not be resolved.'); + } + + $now = new DateTime(); + $clientId = self::CLIENT_ID_PREFIX.$this->randomToken(); + + $client = (new OAuthClient()) + ->setClientId($clientId) + ->setTokenEndpointAuthMethod($authMethod) + ->setClientName($this->truncate($metadata['client_name'] ?? null, 255)) + ->setClientUri($this->truncate($metadata['client_uri'] ?? null, self::MAX_URI_LENGTH)) + ->setLogoUri($this->truncate($metadata['logo_uri'] ?? null, self::MAX_URI_LENGTH)) + ->setPolicyUri($this->truncate($metadata['policy_uri'] ?? null, self::MAX_URI_LENGTH)) + ->setTosUri($this->truncate($metadata['tos_uri'] ?? null, self::MAX_URI_LENGTH)) + ->setSoftwareId($this->truncate($metadata['software_id'] ?? null, 255)) + ->setSoftwareVersion($this->truncate($metadata['software_version'] ?? null, 64)) + ->setRedirectUris($redirectUris) + ->setGrantTypes($grantTypes) + ->setResponseTypes($responseTypes) + ->setScope('mcp') + ->setAccessUrlId((int) $accessUrl->getId()) + ->setRegistrationIp($clientIp) + ->setCreatedAt($now) + ; + + $plainSecret = null; + if ('none' !== $authMethod) { + $plainSecret = $this->randomToken(); + $client + ->setClientSecretHash(hash('sha256', $plainSecret)) + ->setClientSecretPrefix(mb_substr($plainSecret, 0, 12)) + ; + } + + $this->entityManager->persist($client); + $this->entityManager->flush(); + + $response = [ + 'client_id' => $clientId, + 'client_id_issued_at' => $now->getTimestamp(), + 'redirect_uris' => $redirectUris, + 'grant_types' => $grantTypes, + 'response_types' => $responseTypes, + 'token_endpoint_auth_method' => $authMethod, + 'scope' => 'mcp', + ]; + + foreach (['client_name', 'client_uri', 'logo_uri', 'policy_uri', 'tos_uri', 'software_id', 'software_version'] as $field) { + if (isset($metadata[$field])) { + $response[$field] = $metadata[$field]; + } + } + + if (null !== $plainSecret) { + // Returned once, at registration time only — there is no client + // configuration endpoint (RFC 7592) to retrieve it again later. + $response['client_secret'] = $plainSecret; + $response['client_secret_expires_at'] = 0; + } + + return $response; + } + + /** + * @return array + */ + private function validateRedirectUris(mixed $value): array + { + if (!\is_array($value) || [] === $value) { + throw OAuthException::invalidClientMetadata('redirect_uris is required and must be a non-empty array.'); + } + + if (\count($value) > self::MAX_REDIRECT_URIS) { + throw OAuthException::invalidClientMetadata(\sprintf('A maximum of %d redirect_uris is allowed.', self::MAX_REDIRECT_URIS)); + } + + $result = []; + foreach ($value as $uri) { + if (!\is_string($uri) || '' === $uri || mb_strlen($uri) > self::MAX_URI_LENGTH) { + throw OAuthException::invalidRedirectUri('Each redirect_uri must be a non-empty string.'); + } + + $parts = parse_url($uri); + if (false === $parts || !isset($parts['scheme'], $parts['host'])) { + throw OAuthException::invalidRedirectUri(\sprintf('"%s" is not a valid absolute URI.', $uri)); + } + + if (isset($parts['fragment'])) { + throw OAuthException::invalidRedirectUri('redirect_uri must not contain a fragment.'); + } + + $isHttps = 'https' === $parts['scheme']; + $isLoopback = 'http' === $parts['scheme'] + && \in_array($parts['host'], ['localhost', '127.0.0.1', '::1'], true); + + if (!$isHttps && !$isLoopback) { + throw OAuthException::invalidRedirectUri('redirect_uri must use https, or http restricted to localhost/127.0.0.1/[::1].'); + } + + $result[] = $uri; + } + + return $result; + } + + /** + * @param array $allowed + * @param array $default + * + * @return array + */ + private function validateSubset(mixed $value, array $allowed, array $default): array + { + if (null === $value) { + return $default; + } + + if (!\is_array($value) || [] === $value) { + throw OAuthException::invalidClientMetadata('Invalid metadata array.'); + } + + foreach ($value as $item) { + if (!\is_string($item) || !\in_array($item, $allowed, true)) { + throw OAuthException::invalidClientMetadata(\sprintf('"%s" is not supported.', (string) $item)); + } + } + + return array_values(array_unique($value)); + } + + private function validateAuthMethod(mixed $value): string + { + if (!\is_string($value) || !\in_array($value, self::ALLOWED_AUTH_METHODS, true)) { + throw OAuthException::invalidClientMetadata('Unsupported token_endpoint_auth_method.'); + } + + return $value; + } + + private function truncate(mixed $value, int $maxLength): ?string + { + if (!\is_string($value) || '' === $value) { + return null; + } + + return mb_substr($value, 0, $maxLength); + } + + private function randomToken(): string + { + return rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); + } +} diff --git a/src/CoreBundle/Service/OAuthServer/OAuthClientResolver.php b/src/CoreBundle/Service/OAuthServer/OAuthClientResolver.php new file mode 100644 index 00000000000..a522fb52225 --- /dev/null +++ b/src/CoreBundle/Service/OAuthServer/OAuthClientResolver.php @@ -0,0 +1,90 @@ +requireAccessUrl(); + + return $this->clientRepository->findActiveByClientIdAndAccessUrl($clientId, (int) $accessUrl->getId()); + } + + /** + * Authenticates the client making the request per RFC 6749 §2.3: + * client_id (+ optional client_secret) in the body, or HTTP Basic. Public + * clients (token_endpoint_auth_method "none") only need a valid client_id. + */ + public function authenticateFromRequest(Request $request): OAuthClient + { + $clientId = (string) $request->request->get('client_id', ''); + $clientSecret = (string) $request->request->get('client_secret', ''); + $usedBasicAuth = false; + + if ('' === $clientId) { + $header = (string) $request->headers->get('Authorization', ''); + if (str_starts_with($header, 'Basic ')) { + $decoded = base64_decode(mb_substr($header, 6), true); + if (\is_string($decoded) && str_contains($decoded, ':')) { + [$rawClientId, $rawClientSecret] = explode(':', $decoded, 2); + $clientId = rawurldecode($rawClientId); + $clientSecret = rawurldecode($rawClientSecret); + $usedBasicAuth = true; + } + } + } + + if ('' === $clientId) { + throw OAuthException::invalidClient(); + } + + $client = $this->resolveActive($clientId); + if (!$client instanceof OAuthClient) { + throw OAuthException::invalidClient(); + } + + if ($client->isPublicClient()) { + return $client; + } + + $hash = $client->getClientSecretHash(); + if (null === $hash || '' === $clientSecret || !hash_equals($hash, hash('sha256', $clientSecret))) { + throw OAuthException::invalidClient('Client authentication failed.', $usedBasicAuth ? ['WWW-Authenticate' => 'Basic'] : []); + } + + return $client; + } + + private function requireAccessUrl(): AccessUrl + { + $accessUrl = $this->accessUrlHelper->getCurrent(); + if (!$accessUrl instanceof AccessUrl || null === $accessUrl->getId()) { + throw new RuntimeException('The current access URL could not be resolved.'); + } + + return $accessUrl; + } +} diff --git a/src/CoreBundle/Service/OAuthServer/OAuthGrantManager.php b/src/CoreBundle/Service/OAuthServer/OAuthGrantManager.php new file mode 100644 index 00000000000..a4eaa4a7f39 --- /dev/null +++ b/src/CoreBundle/Service/OAuthServer/OAuthGrantManager.php @@ -0,0 +1,108 @@ +> + */ + public function listForCurrentUser(): array + { + [$user, $accessUrl] = $this->resolveCurrentContext(); + $now = new DateTime(); + $grants = $this->refreshTokenRepository->findActiveGrantsForUserAndAccessUrl( + (int) $user->getId(), + (int) $accessUrl->getId(), + $now, + ); + + return array_map($this->normalize(...), $grants); + } + + public function revokeForCurrentUser(string $grantId): void + { + [$user, $accessUrl] = $this->resolveCurrentContext(); + $now = new DateTime(); + $grant = $this->refreshTokenRepository->findActiveGrantByIdForUser( + $grantId, + (int) $user->getId(), + (int) $accessUrl->getId(), + $now, + ); + + if (!$grant instanceof OAuthRefreshToken) { + throw new NotFoundHttpException('This authorized application was not found.'); + } + + $this->refreshTokenRepository->revokeFamily($grant->getGrantId(), OAuthRefreshToken::REVOKED_REASON_USER, $now); + } + + /** + * @return array{0: User, 1: AccessUrl} + */ + private function resolveCurrentContext(): array + { + $user = $this->userHelper->getCurrent(); + if (!$user instanceof User || null === $user->getId()) { + throw new AccessDeniedException('Authentication is required.'); + } + + $accessUrl = $this->accessUrlHelper->getCurrent(); + if (!$accessUrl instanceof AccessUrl || null === $accessUrl->getId()) { + throw new RuntimeException('The current access URL could not be resolved.'); + } + + if (!$this->accessUrlRepository->isUrlActiveForUser($accessUrl, $user)) { + throw new AccessDeniedException('The authenticated user is not active on this access URL.'); + } + + return [$user, $accessUrl]; + } + + /** + * @return array + */ + private function normalize(OAuthRefreshToken $grant): array + { + return [ + 'id' => $grant->getGrantId(), + 'clientName' => $grant->getClient()->getClientName() ?? 'Unknown application', + 'clientUri' => $grant->getClient()->getClientUri(), + 'connectedAt' => $grant->getConsentedAt()->format(DATE_ATOM), + 'lastUsedAt' => $grant->getLastUsedAt()?->format(DATE_ATOM), + 'expiresAt' => $grant->getExpiresAt()->format(DATE_ATOM), + 'scope' => $grant->getScope(), + ]; + } +} diff --git a/src/CoreBundle/Service/OAuthServer/OAuthMetadataService.php b/src/CoreBundle/Service/OAuthServer/OAuthMetadataService.php new file mode 100644 index 00000000000..ffdebdcbe12 --- /dev/null +++ b/src/CoreBundle/Service/OAuthServer/OAuthMetadataService.php @@ -0,0 +1,119 @@ +requestStack->getCurrentRequest(); + if (null === $request) { + throw new RuntimeException('The OAuth issuer requires an active HTTP request.'); + } + + return rtrim($request->getSchemeAndHttpHost().$request->getBaseUrl(), '/'); + } + + public function getAuthorizationEndpoint(): string + { + return $this->getIssuer().'/oauth/authorize'; + } + + public function getTokenEndpoint(): string + { + return $this->getIssuer().'/oauth/token'; + } + + public function getRegistrationEndpoint(): string + { + return $this->getIssuer().'/oauth/register'; + } + + public function getRevocationEndpoint(): string + { + return $this->getIssuer().'/oauth/revoke'; + } + + /** + * Builds the audience identifier for a given resource server path, e.g. + * "/mcp" -> "/mcp". Not validated against a registry — each + * resource server owns its own identifier. + */ + public function getResourceIdentifier(string $resourcePath): string + { + return $this->getIssuer().'/'.ltrim($resourcePath, '/'); + } + + /** + * The RFC 9728 metadata URL a resource server should advertise in its + * WWW-Authenticate header. $resourcePath (e.g. "mcp") is appended per the + * spec's path-insertion convention so clients that probe the + * path-suffixed form first still find it. + */ + public function getResourceMetadataUrl(string $resourcePath = ''): string + { + $base = $this->getIssuer().'/.well-known/oauth-protected-resource'; + $resourcePath = trim($resourcePath, '/'); + + return '' === $resourcePath ? $base : $base.'/'.$resourcePath; + } + + /** + * @return array + */ + public function buildAuthorizationServerMetadata(): array + { + return [ + 'issuer' => $this->getIssuer(), + 'authorization_endpoint' => $this->getAuthorizationEndpoint(), + 'token_endpoint' => $this->getTokenEndpoint(), + 'registration_endpoint' => $this->getRegistrationEndpoint(), + 'revocation_endpoint' => $this->getRevocationEndpoint(), + 'scopes_supported' => ['mcp'], + 'response_types_supported' => ['code'], + 'response_modes_supported' => ['query'], + 'grant_types_supported' => ['authorization_code', 'refresh_token'], + 'token_endpoint_auth_methods_supported' => ['none', 'client_secret_post', 'client_secret_basic'], + 'revocation_endpoint_auth_methods_supported' => ['none', 'client_secret_post', 'client_secret_basic'], + 'code_challenge_methods_supported' => ['S256'], + 'service_documentation' => $this->getIssuer().'/documentation/', + ]; + } + + /** + * @return array + */ + public function buildProtectedResourceMetadata(string $resourcePath): array + { + $resourceName = (string) ($this->settingsManager->getSetting('platform.site_name') ?: 'Chamilo'); + + return [ + 'resource' => $this->getResourceIdentifier($resourcePath), + 'authorization_servers' => [$this->getIssuer()], + 'bearer_methods_supported' => ['header'], + 'scopes_supported' => ['mcp'], + 'resource_name' => $resourceName, + 'resource_documentation' => $this->getIssuer().'/documentation/', + ]; + } +} diff --git a/src/CoreBundle/Service/OAuthServer/OAuthTokenService.php b/src/CoreBundle/Service/OAuthServer/OAuthTokenService.php new file mode 100644 index 00000000000..5dd8c160690 --- /dev/null +++ b/src/CoreBundle/Service/OAuthServer/OAuthTokenService.php @@ -0,0 +1,320 @@ + $params + * + * @return array + */ + public function exchangeAuthorizationCode(OAuthClient $client, array $params): array + { + $plainCode = (string) ($params['code'] ?? ''); + $redirectUri = (string) ($params['redirect_uri'] ?? ''); + $codeVerifier = (string) ($params['code_verifier'] ?? ''); + + if ('' === $plainCode || '' === $redirectUri || '' === $codeVerifier) { + throw OAuthException::invalidRequest('code, redirect_uri and code_verifier are required.'); + } + + $code = $this->codeRepository->findOneByHash(hash('sha256', $plainCode)); + if (!$code instanceof OAuthAuthorizationCode) { + throw OAuthException::invalidGrant(); + } + + $now = new DateTime(); + + if (!$this->codeRepository->consumeAtomically((int) $code->getId(), $now)) { + // Already used: either a genuine replay, or two concurrent + // exchange attempts raced. Either way, revoke anything this code + // may have already produced (harmless no-op if nothing exists yet). + $this->revokeFamily($code->getGrantId(), OAuthRefreshToken::REVOKED_REASON_REUSE_DETECTED, $now); + + throw OAuthException::invalidGrant(); + } + + if ($code->getExpiresAt() <= $now) { + throw OAuthException::invalidGrant('The authorization code has expired.'); + } + + if ($code->getClient()->getId() !== $client->getId()) { + throw OAuthException::invalidGrant(); + } + + if (!hash_equals($code->getRedirectUri(), $redirectUri)) { + throw OAuthException::invalidGrant('redirect_uri does not match the one used to obtain this code.'); + } + + if (!$this->verifyPkce($codeVerifier, $code->getCodeChallenge())) { + throw OAuthException::invalidGrant('PKCE verification failed.'); + } + + $user = $code->getUser(); + if (!$this->isUserActiveOnCurrentPortal($user)) { + throw OAuthException::invalidGrant(); + } + + return $this->issueTokenPair( + client: $client, + user: $user, + grantId: $code->getGrantId(), + scope: $code->getScope(), + resource: $code->getResource(), + consentedAt: $code->getCreatedAt(), + consentIp: $code->getConsentIp(), + consentUserAgent: $code->getConsentUserAgent(), + now: $now, + ); + } + + /** + * @param array $params + * + * @return array + */ + public function refresh(OAuthClient $client, array $params): array + { + $plainRefreshToken = (string) ($params['refresh_token'] ?? ''); + if ('' === $plainRefreshToken) { + throw OAuthException::invalidRequest('refresh_token is required.'); + } + + $refreshToken = $this->refreshTokenRepository->findOneByHash(hash('sha256', $plainRefreshToken)); + if (!$refreshToken instanceof OAuthRefreshToken) { + throw OAuthException::invalidGrant(); + } + + $now = new DateTime(); + + if (null !== $refreshToken->getRotatedAt() || null !== $refreshToken->getRevokedAt()) { + // A rotated-away or already-revoked generation was presented + // again: this is the refresh-token-reuse signal OAuth 2.1 + // rotation exists to catch. Kill the whole family. + $this->revokeFamily($refreshToken->getGrantId(), OAuthRefreshToken::REVOKED_REASON_REUSE_DETECTED, $now); + + throw OAuthException::invalidGrant(); + } + + if ($refreshToken->getExpiresAt() <= $now || $refreshToken->getAbsoluteExpiresAt() <= $now) { + throw OAuthException::invalidGrant('The refresh token has expired.'); + } + + if ($refreshToken->getClient()->getId() !== $client->getId()) { + throw OAuthException::invalidGrant(); + } + + $user = $refreshToken->getUser(); + if (!$this->isUserActiveOnCurrentPortal($user)) { + throw OAuthException::invalidGrant(); + } + + if (!$this->refreshTokenRepository->rotateAtomically((int) $refreshToken->getId(), $now)) { + // Lost a race against another refresh attempt on the same token. + $this->revokeFamily($refreshToken->getGrantId(), OAuthRefreshToken::REVOKED_REASON_REUSE_DETECTED, $now); + + throw OAuthException::invalidGrant(); + } + + return $this->issueTokenPair( + client: $client, + user: $user, + grantId: $refreshToken->getGrantId(), + scope: $refreshToken->getScope(), + resource: $refreshToken->getResource(), + consentedAt: $refreshToken->getConsentedAt(), + consentIp: $refreshToken->getConsentIp(), + consentUserAgent: $refreshToken->getConsentUserAgent(), + now: $now, + absoluteExpiresAt: $refreshToken->getAbsoluteExpiresAt(), + replaces: $refreshToken, + ); + } + + public function revoke(OAuthClient $client, string $plainToken, ?string $tokenTypeHint): void + { + $now = new DateTime(); + $hash = hash('sha256', $plainToken); + + $checkOrder = 'access_token' === $tokenTypeHint + ? ['access', 'refresh'] + : ['refresh', 'access']; + + foreach ($checkOrder as $type) { + if ('refresh' === $type) { + $refreshToken = $this->refreshTokenRepository->findOneByHash($hash); + if ($refreshToken instanceof OAuthRefreshToken && $refreshToken->getClient()->getId() === $client->getId()) { + $this->revokeFamily($refreshToken->getGrantId(), OAuthRefreshToken::REVOKED_REASON_CLIENT_REVOKED, $now); + + return; + } + } else { + $accessToken = $this->accessTokenRepository->findActiveByHash($hash, $now); + if ($accessToken instanceof OAuthAccessToken && $accessToken->getClient()->getId() === $client->getId()) { + $this->revokeFamily($accessToken->getGrantId(), OAuthRefreshToken::REVOKED_REASON_CLIENT_REVOKED, $now); + + return; + } + } + } + + // RFC 7009 §2.2: unknown, foreign, or already-revoked tokens are not + // an error — never disclose which is the case. + } + + private function revokeFamily(string $grantId, string $reason, DateTime $now): void + { + $this->refreshTokenRepository->revokeFamily($grantId, $reason, $now); + $this->accessTokenRepository->revokeByGrantId($grantId, $now); + } + + private function verifyPkce(string $verifier, string $challenge): bool + { + if (1 !== preg_match('/^[A-Za-z0-9\-._~]{43,128}$/', $verifier)) { + return false; + } + + $computed = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); + + return hash_equals($challenge, $computed); + } + + private function isUserActiveOnCurrentPortal(User $user): bool + { + if (User::ACTIVE !== $user->getActive()) { + return false; + } + + $expirationDate = $user->getExpirationDate(); + if (null !== $expirationDate && $expirationDate <= new DateTime()) { + return false; + } + + $accessUrl = $this->accessUrlHelper->getCurrent(); + if (null === $accessUrl) { + return false; + } + + return $this->accessUrlRepository->isUrlActiveForUser($accessUrl, $user); + } + + /** + * @return array + */ + private function issueTokenPair( + OAuthClient $client, + User $user, + string $grantId, + ?string $scope, + ?string $resource, + DateTime $consentedAt, + ?string $consentIp, + ?string $consentUserAgent, + DateTime $now, + ?DateTime $absoluteExpiresAt = null, + ?OAuthRefreshToken $replaces = null, + ): array { + $accessUrl = $this->accessUrlHelper->getCurrent(); + $accessUrlId = null !== $accessUrl ? (int) $accessUrl->getId() : null; + + $plainAccessToken = self::ACCESS_TOKEN_PREFIX.$this->randomSecret(); + $accessToken = (new OAuthAccessToken()) + ->setTokenHash(hash('sha256', $plainAccessToken)) + ->setTokenPrefix(mb_substr($plainAccessToken, 0, 24)) + ->setGrantId($grantId) + ->setClient($client) + ->setUser($user) + ->setAccessUrlId($accessUrlId) + ->setScope($scope) + ->setResource($resource) + ->setCreatedAt($now) + ->setExpiresAt((clone $now)->modify('+'.self::ACCESS_TOKEN_TTL_SECONDS.' seconds')) + ; + $this->entityManager->persist($accessToken); + + $plainRefreshToken = self::REFRESH_TOKEN_PREFIX.$this->randomSecret(); + $newAbsoluteExpiresAt = $absoluteExpiresAt ?? (clone $consentedAt)->modify('+'.self::REFRESH_TOKEN_ABSOLUTE_TTL_SECONDS.' seconds'); + $slidingExpiresAt = (clone $now)->modify('+'.self::REFRESH_TOKEN_TTL_SECONDS.' seconds'); + $refreshExpiresAt = $slidingExpiresAt < $newAbsoluteExpiresAt ? $slidingExpiresAt : $newAbsoluteExpiresAt; + + $refreshToken = (new OAuthRefreshToken()) + ->setTokenHash(hash('sha256', $plainRefreshToken)) + ->setGrantId($grantId) + ->setClient($client) + ->setUser($user) + ->setAccessUrlId($accessUrlId) + ->setScope($scope) + ->setResource($resource) + ->setConsentedAt($consentedAt) + ->setConsentIp($consentIp) + ->setConsentUserAgent($consentUserAgent) + ->setCreatedAt($now) + ->setExpiresAt($refreshExpiresAt) + ->setAbsoluteExpiresAt($newAbsoluteExpiresAt) + ; + $this->entityManager->persist($refreshToken); + + if (null !== $replaces) { + $replaces->setReplacedBy($refreshToken); + } + + $this->entityManager->flush(); + + return [ + 'access_token' => $plainAccessToken, + 'token_type' => 'Bearer', + 'expires_in' => self::ACCESS_TOKEN_TTL_SECONDS, + 'refresh_token' => $plainRefreshToken, + 'scope' => $scope ?? 'mcp', + ]; + } + + private function randomSecret(): string + { + return rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); + } +} diff --git a/src/CoreBundle/Settings/SecuritySettingsSchema.php b/src/CoreBundle/Settings/SecuritySettingsSchema.php index b4b77a3e6bf..4febc8488a2 100644 --- a/src/CoreBundle/Settings/SecuritySettingsSchema.php +++ b/src/CoreBundle/Settings/SecuritySettingsSchema.php @@ -49,6 +49,7 @@ public function buildSettings(AbstractSettingsBuilder $builder): void 'force_renew_password_at_first_login' => 'false', 'hide_breadcrumb_if_not_allowed' => 'false', 'file_integrity_check_notify_admins' => '', + 'oauth_server_enabled' => 'false', ]); $allowedTypes = [ @@ -92,6 +93,7 @@ public function buildForm(FormBuilderInterface $builder): void ->add('force_renew_password_at_first_login', YesNoType::class) ->add('hide_breadcrumb_if_not_allowed', YesNoType::class) ->add('file_integrity_check_notify_admins', TextareaType::class) + ->add('oauth_server_enabled', YesNoType::class) ; $this->updateFormFieldsFromSettingsInfo($builder); diff --git a/src/CoreBundle/State/OAuthServer/OAuthConnectedAppProcessor.php b/src/CoreBundle/State/OAuthServer/OAuthConnectedAppProcessor.php new file mode 100644 index 00000000000..ee9fcbce6c6 --- /dev/null +++ b/src/CoreBundle/State/OAuthServer/OAuthConnectedAppProcessor.php @@ -0,0 +1,34 @@ + + */ +final readonly class OAuthConnectedAppProcessor implements ProcessorInterface +{ + public function __construct( + private OAuthGrantManager $grantManager, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if ($operation instanceof Delete) { + $this->grantManager->revokeForCurrentUser((string) $uriVariables['id']); + + return null; + } + + throw new LogicException('Unsupported OAuth connected app operation.'); + } +} diff --git a/src/CoreBundle/State/OAuthServer/OAuthConnectedAppProvider.php b/src/CoreBundle/State/OAuthServer/OAuthConnectedAppProvider.php new file mode 100644 index 00000000000..666f6514239 --- /dev/null +++ b/src/CoreBundle/State/OAuthServer/OAuthConnectedAppProvider.php @@ -0,0 +1,33 @@ + + */ +final readonly class OAuthConnectedAppProvider implements ProviderInterface +{ + public function __construct( + private OAuthGrantManager $grantManager, + ) {} + + /** + * @return array + */ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array + { + return array_map( + OAuthConnectedApp::fromArray(...), + $this->grantManager->listForCurrentUser(), + ); + } +} diff --git a/tests/CoreBundle/Api/OAuthConnectedAppApiSecurityTest.php b/tests/CoreBundle/Api/OAuthConnectedAppApiSecurityTest.php new file mode 100644 index 00000000000..2f8dac69f03 --- /dev/null +++ b/tests/CoreBundle/Api/OAuthConnectedAppApiSecurityTest.php @@ -0,0 +1,142 @@ +get(EntityManagerInterface::class); + + $client = (new OAuthClient()) + ->setClientId('chamilo_oauth_client_test_'.$suffix) + ->setTokenEndpointAuthMethod('none') + ->setClientName('Test Client '.$suffix) + ->setRedirectUris(['https://client.example/callback']) + ->setGrantTypes(['authorization_code', 'refresh_token']) + ->setResponseTypes(['code']) + ->setScope('mcp') + ->setCreatedAt(new DateTime()) + ; + + $em->persist($client); + $em->flush(); + + return $client; + } + + private function seedGrant(User $owner, OAuthClient $client, string $suffix): OAuthRefreshToken + { + /** @var EntityManagerInterface $em */ + $em = self::getContainer()->get(EntityManagerInterface::class); + + $now = new DateTime(); + $grant = (new OAuthRefreshToken()) + ->setTokenHash(hash('sha256', 'test-refresh-token-'.$suffix)) + ->setGrantId(Uuid::v4()->toRfc4122()) + ->setClient($client) + ->setUser($owner) + ->setScope('mcp') + ->setConsentedAt($now) + ->setCreatedAt($now) + ->setExpiresAt((clone $now)->modify('+30 days')) + ->setAbsoluteExpiresAt((clone $now)->modify('+90 days')) + ; + + $em->persist($grant); + $em->flush(); + + return $grant; + } + + public function testForeignUserCannotRevokeOthersGrant(): void + { + $victim = $this->createUser('oauth_sec_victim_revoke'); + $attacker = $this->createUser('oauth_sec_attacker_revoke'); + $client = $this->seedClient('revoke'); + $grant = $this->seedGrant($victim, $client, 'revoke'); + + $token = $this->getUserTokenFromUser($attacker); + $this->createClientWithCredentials($token)->request( + 'DELETE', + '/api/oauth_connected_apps/'.$grant->getGrantId(), + ); + + // Existence of a foreign grant is never disclosed: not found, not forbidden. + $this->assertResponseStatusCodeSame(404); + } + + public function testOwnerCanRevokeOwnGrant(): void + { + $owner = $this->createUser('oauth_sec_owner_revoke'); + $client = $this->seedClient('owner_revoke'); + $grant = $this->seedGrant($owner, $client, 'owner_revoke'); + + $token = $this->getUserTokenFromUser($owner); + $this->createClientWithCredentials($token)->request( + 'DELETE', + '/api/oauth_connected_apps/'.$grant->getGrantId(), + ); + + $this->assertResponseStatusCodeSame(204); + } + + public function testCollectionOnlyReturnsOwnGrants(): void + { + $victim = $this->createUser('oauth_sec_victim_list'); + $attacker = $this->createUser('oauth_sec_attacker_list'); + $client = $this->seedClient('list'); + $this->seedGrant($victim, $client, 'list_victim'); + $this->seedGrant($attacker, $client, 'list_attacker'); + + $token = $this->getUserTokenFromUser($attacker); + $response = $this->createClientWithCredentials($token)->request( + 'GET', + '/api/oauth_connected_apps', + ); + + $this->assertResponseStatusCodeSame(200); + + $members = $response->toArray()['hydra:member'] ?? []; + $this->assertNotEmpty($members, 'The attacker must still see their own grant.'); + foreach ($members as $member) { + $this->assertArrayNotHasKey('tokenHash', $member, 'The refresh token hash must never be serialized.'); + } + } + + public function testRevokingAnUnknownGrantIdReturnsNotFound(): void + { + $user = $this->createUser('oauth_sec_unknown_grant'); + + $token = $this->getUserTokenFromUser($user); + $this->createClientWithCredentials($token)->request( + 'DELETE', + '/api/oauth_connected_apps/'.Uuid::v4()->toRfc4122(), + ); + + $this->assertResponseStatusCodeSame(404); + } +} diff --git a/tests/CoreBundle/Security/OAuthTokenPrefixCollisionTest.php b/tests/CoreBundle/Security/OAuthTokenPrefixCollisionTest.php new file mode 100644 index 00000000000..2e223cb4a81 --- /dev/null +++ b/tests/CoreBundle/Security/OAuthTokenPrefixCollisionTest.php @@ -0,0 +1,46 @@ +expectRegistrationError([], 'invalid_client_metadata'); + } + + public function testItRejectsMoreThanFiveRedirectUris(): void + { + $uris = []; + for ($i = 0; $i < 6; ++$i) { + $uris[] = "https://client.example/callback{$i}"; + } + + $this->expectRegistrationError(['redirect_uris' => $uris], 'invalid_client_metadata'); + } + + public function testItRejectsNonHttpsNonLoopbackRedirectUri(): void + { + $this->expectRegistrationError( + ['redirect_uris' => ['http://evil.example/callback']], + 'invalid_redirect_uri', + ); + } + + public function testItRejectsRedirectUriWithFragment(): void + { + $this->expectRegistrationError( + ['redirect_uris' => ['https://client.example/callback#frag']], + 'invalid_redirect_uri', + ); + } + + public function testItRejectsMalformedRedirectUri(): void + { + $this->expectRegistrationError( + ['redirect_uris' => ['not-a-uri']], + 'invalid_redirect_uri', + ); + } + + public function testItAcceptsHttpsRedirectUri(): void + { + $response = $this->register(['redirect_uris' => ['https://client.example/callback']]); + + self::assertSame(['https://client.example/callback'], $response['redirect_uris']); + self::assertArrayNotHasKey('client_secret', $response); + } + + public function testItAcceptsLoopbackHttpRedirectUri(): void + { + $response = $this->register(['redirect_uris' => ['http://127.0.0.1:8765/callback']]); + + self::assertSame(['http://127.0.0.1:8765/callback'], $response['redirect_uris']); + } + + public function testItDefaultsToPublicClientWithNoSecret(): void + { + $response = $this->register(['redirect_uris' => ['https://client.example/callback']]); + + self::assertSame('none', $response['token_endpoint_auth_method']); + self::assertArrayNotHasKey('client_secret', $response); + } + + public function testItIssuesASecretForConfidentialClients(): void + { + $response = $this->register([ + 'redirect_uris' => ['https://client.example/callback'], + 'token_endpoint_auth_method' => 'client_secret_post', + ]); + + self::assertArrayHasKey('client_secret', $response); + self::assertSame(0, $response['client_secret_expires_at']); + } + + public function testItRejectsUnsupportedGrantType(): void + { + $this->expectRegistrationError( + [ + 'redirect_uris' => ['https://client.example/callback'], + 'grant_types' => ['client_credentials'], + ], + 'invalid_client_metadata', + ); + } + + /** + * @param array $metadata + */ + private function expectRegistrationError(array $metadata, string $expectedErrorCode): void + { + try { + $this->register($metadata); + self::fail('Expected an OAuthException to be thrown.'); + } catch (OAuthException $exception) { + self::assertSame($expectedErrorCode, $exception->getErrorCode()); + } + } + + /** + * @param array $metadata + * + * @return array + */ + private function register(array $metadata): array + { + $accessUrl = new AccessUrl(); + (new ReflectionProperty(AccessUrl::class, 'id'))->setValue($accessUrl, 1); + + $accessUrlRepository = $this->createMock(AccessUrlRepository::class); + $accessUrlRepository->method('getFirstId')->willReturn(1); + $accessUrlRepository->method('find')->with(1)->willReturn($accessUrl); + $accessUrlHelper = new AccessUrlHelper($accessUrlRepository, new RequestStack()); + + $entityManager = $this->createMock(EntityManagerInterface::class); + + $registrar = new OAuthClientRegistrar($entityManager, $accessUrlHelper); + + return $registrar->register($metadata, '127.0.0.1'); + } +} From 0e9d4cfc1a42ea6f766a78a447908b4e2a79f9f0 Mon Sep 17 00:00:00 2001 From: Yannick Warnier Date: Tue, 28 Jul 2026 02:48:46 +0200 Subject: [PATCH 2/2] MCP: Disable allowed-hosts DNS rebinding protection for MCP (superseeded by Symfony's OAuth DNS rebinding protection) - refs #8742 --- config/packages/mcp.yaml | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/config/packages/mcp.yaml b/config/packages/mcp.yaml index 5dfd2c88e10..bd007d848fb 100644 --- a/config/packages/mcp.yaml +++ b/config/packages/mcp.yaml @@ -11,10 +11,19 @@ mcp: http: true http: path: /mcp - allowed_hosts: - - 'chamilo2.local' - - 'localhost' - - '127.0.0.1' + # false (not omitted) explicitly disables the SDK's DNS-rebinding-protection + # middleware. Omitting this key instead falls back to the SDK default of + # localhost-only, which would block every real request on a public server. + # This is the bundle's own sanctioned way to "expose a public MCP server" + # (see Symfony\AI\McpBundle\Http\MiddlewareFactory) — Chamilo doesn't need + # that check on top: /mcp already requires a Bearer credential (personal API + # key, JWT, or OAuth access token) via McpBearerAuthenticator regardless of + # the request's Host/Origin header, so DNS rebinding (which relies on + # ambient/cookie-style auth riding along with a spoofed Host) isn't + # exploitable here — a rebinding page cannot forge a bearer token it doesn't + # already have. A multi-AccessUrl platform reachable under many hostnames + # also makes a static per-host allowlist a poor fit operationally. + allowed_hosts: false session: store: file directory: '%kernel.cache_dir%/mcp-sessions'