Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions assets/vue/components/social/UserProfileCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,30 @@
aria-hidden="true"
></i>
</button>

<button
type="button"
class="group flex min-h-[56px] w-full items-center rounded-xl bg-secondary px-4 py-3 text-left text-secondary-button-text shadow-sm transition hover:opacity-95 hover:shadow-xl"
@click="manageAuthorizedApplications"
>
<span
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-white text-secondary shadow-sm"
>
<i
class="mdi mdi-shield-key-outline text-lg"
aria-hidden="true"
></i>
</span>

<span class="ml-3 min-w-0 flex-1 text-body-2 font-semibold leading-snug">
{{ t("Authorized applications") }}
</span>

<i
class="mdi mdi-chevron-right ml-2 text-lg opacity-80 transition group-hover:translate-x-0.5"
aria-hidden="true"
></i>
</button>
</div>
</div>
</div>
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions assets/vue/router/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
},
],
}
18 changes: 18 additions & 0 deletions assets/vue/services/oauthConnectedAppService.js
Original file line number Diff line number Diff line change
@@ -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,
}
146 changes: 146 additions & 0 deletions assets/vue/views/user/AuthorizedApplications.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<template>
<div class="flex flex-col gap-4">
<BaseCard
class="bg-white"
plain
>
<template #header>
<div class="px-4 py-2 -mb-2 bg-gray-15">
<h2 class="text-h5">{{ t("Authorized applications") }}</h2>
</div>
</template>

<hr class="-mt-2 mb-4 -mx-4" />

<div class="space-y-4">
<p>
{{
t(
"These applications can access Chamilo as you, using your existing account permissions and nothing more. Revoke any you no longer use or recognize.",
)
}}
</p>

<div
v-if="isLoading"
class="text-sm text-gray-50"
>
{{ t("Loading...") }}
</div>

<div
v-else-if="apps.length === 0"
class="rounded-xl border border-gray-25 bg-gray-10 p-4 text-sm text-gray-50"
>
{{ t("No applications are currently connected to your account.") }}
</div>

<div
v-else
class="space-y-3"
>
<div
v-for="app in apps"
:key="app.id"
class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-gray-25 p-4"
>
<div class="min-w-0">
<p class="font-semibold text-gray-90">{{ app.clientName }}</p>
<p class="text-sm text-gray-50">
{{ t("Connected on") }}: {{ formatDate(app.connectedAt) }}
<template v-if="app.lastUsedAt"> · {{ t("Last used") }}: {{ formatDate(app.lastUsedAt) }}</template>
</p>
</div>

<BaseButton
:disabled="isRevoking === app.id"
:is-loading="isRevoking === app.id"
:label="t('Revoke')"
icon="delete"
type="danger"
@click="confirmRevoke(app)"
/>
</div>
</div>
</div>
</BaseCard>
</div>
</template>

<script setup>
import { onMounted, ref } from "vue"
import { useI18n } from "vue-i18n"
import BaseButton from "../../components/basecomponents/BaseButton.vue"
import BaseCard from "../../components/basecomponents/BaseCard.vue"
import { useConfirmation } from "../../composables/useConfirmation"
import { useNotification } from "../../composables/notification"
import oauthConnectedAppService from "../../services/oauthConnectedAppService"

const { t } = useI18n()
const { requireConfirmation } = useConfirmation()
const notifications = useNotification()

const isLoading = ref(false)
const isRevoking = ref(null)
const apps = ref([])

function getErrorMessage(error) {
return (
error?.response?.data?.detail ||
error?.response?.data?.["hydra:description"] ||
error?.response?.data?.message ||
t("The operation could not be completed.")
)
}

function formatDate(value) {
if (!value) {
return "-"
}

const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}

return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date)
}

async function loadApps() {
isLoading.value = true

try {
apps.value = await oauthConnectedAppService.list()
} catch (error) {
console.error("Error loading authorized applications", error)
notifications.showErrorNotification(getErrorMessage(error))
} finally {
isLoading.value = false
}
}

function confirmRevoke(app) {
requireConfirmation({
title: t("Revoke"),
message: t("%s will no longer be able to access your Chamilo account. Continue?", [app.clientName]),
accept: () => revokeApp(app),
})
}

async function revokeApp(app) {
isRevoking.value = app.id

try {
await oauthConnectedAppService.revoke(app.id)
apps.value = apps.value.filter((item) => item.id !== app.id)
notifications.showSuccessNotification(t("Application revoked"))
} catch (error) {
console.error("Error revoking authorized application", error)
notifications.showErrorNotification(getErrorMessage(error))
} finally {
isRevoking.value = null
}
}

onMounted(loadApps)
</script>
16 changes: 16 additions & 0 deletions config/packages/framework.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 13 additions & 4 deletions config/packages/mcp.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
13 changes: 13 additions & 0 deletions config/packages/nelmio_cors.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)%']
Expand Down
19 changes: 18 additions & 1 deletion config/packages/security.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 }
Expand Down
Loading
Loading