You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Chamilo's OAuth 2.1 Authorization Server (AS) (OAuthServer namespace) was built specifically so AI models' connectors UI could authenticate against /mcp via Dynamic Client Registration (RFC 7591). As we look at reusing the same AS for other server-to-server integrations down the line (e.g. a storefront plugin, other satellite services), a few hardening gaps become relevant that don't matter for a single first-party MCP consumer but do matter once any self-registered client can, in principle, be presented at any protected resource.
Background
/oauth/register (RFC 7591) is deliberately open/unauthenticated - any party can self-register a client with zero prior coordination. This is correct and shouldn't change; it's the entire point of Dynamic Client Registration.
The real security boundary is the consent step (/oauth/authorize → OAuthAuthorizeController): a registered client can never act as a user without that user explicitly logging in and clicking "Allow." This is already solid: CSRF-protected consent, exact hash_equals() redirect_uri matching, HTTPS-only redirect URIs, mandatory PKCE (S256), no auto-approve path, active-user/active-portal re-checked at both the GET and POST steps.
What's currently missing only bites once there's more than one resource server behind this AS: OAuthClientRegistrar::register() hardcodes scope = 'mcp' for every registrant today, and a token's resource claim (RFC 8707) is stored/echoed but "not validated against a registry (none exists, kept deliberately minimal)" - i.e. any registered client can in principle be presented at any future resource server.
Proposed changes
Per-resource-server client allowlisting. Let each protected resource server (not just /mcp) maintain its own admin-curated list of which client_ids it accepts tokens for, instead of implicitly trusting any registered client for any resource. DB impact: one new JSON column on the existing oauth_client table (allowed_resources), matching how redirectUris/grantTypes/responseTypes are already stored.
Generalize scope beyond the hardcoded 'mcp' value.OAuthClient.scope already exists as a column; OAuthClientRegistrar::register() just needs to stop hardcoding it and instead validate/store whatever the registration requests against a real allowed-scopes list. DB impact: none - column already exists.
Consent screen: show client registration age. Cheap phishing deterrent: surface OAuthClient.createdAt on the consent screen ("this application was registered N days ago") so a brand-new client reads differently from a long-established one. DB impact: none - createdAt already exists and is never deleted (OAuthGarbageCollectorCommand only revokes clients unused 30+ days, it doesn't delete the row).
Consent screen: durable "have you approved this app before?" signal. A user_id + client_id history table decoupled from the OAuth token tables, since OAuthGarbageCollectorCommand deliberately prunes access tokens (7 days after expiry/revocation) and refresh tokens (30 days) to bound row growth - "first time ever" can't be reliably answered from those tables beyond that window. DB impact: yes: one small new table (user_id, client_id, first_granted_at), intentionally never pruned by the GC job.
software_statement support (RFC 7591). Accept an optional signed JWT from a trusted issuer at registration time, verified against a configured public key, as a stronger identity signal than self-asserted metadata. Only worth doing once there's an actual second trusted issuer to verify against. DB impact: Needed only because we also want to persist/display which clients were cryptographically verified (a nullable verifiedIssuer column).
Anomaly monitoring. Alert when one client rapidly accumulates authorizations from many distinct users in a short window - a mass-phishing signature. DB impact: none for live detection (existing OAuthAccessToken.client_id/user_id/created_at covers a recent-burst query within the 7-day retention window). Needed only for durable, queryable alert history rather than a live query/notification.
Database impact summary
Item
Needs schema change?
Per-resource allowlisting
Yes (1 new column)
Generalize scope
No
Show registration age
No
Durable "seen before" history
Yes (1 new small table)
software_statement verification
Yes (for persisting verified-issuer)
Anomaly monitoring
Yes (for durable alert history)
A new setting security.dynamic_client_registration of type true/false should be added (defaults to false) to allow admins to enable this feature (otherwise it remains locked on MCP, if enabled).
Non-goals
Restricting or authenticating who is allowed to register a client at all - intentionally kept open per RFC 7591; the fix is scoping what a registered client can reach, not who can register.
Adding an OAuth client_credentials grant: evaluated and explicitly rejected for server-to-server integrations; those should use a UserApiKey-based static credential instead (see thWordPress storefront plugin integration plan).
Chamilo's OAuth 2.1 Authorization Server (AS) (
OAuthServernamespace) was built specifically so AI models' connectors UI could authenticate against/mcpvia Dynamic Client Registration (RFC 7591). As we look at reusing the same AS for other server-to-server integrations down the line (e.g. a storefront plugin, other satellite services), a few hardening gaps become relevant that don't matter for a single first-party MCP consumer but do matter once any self-registered client can, in principle, be presented at any protected resource.Background
/oauth/register(RFC 7591) is deliberately open/unauthenticated - any party can self-register a client with zero prior coordination. This is correct and shouldn't change; it's the entire point of Dynamic Client Registration./oauth/authorize→OAuthAuthorizeController): a registered client can never act as a user without that user explicitly logging in and clicking "Allow." This is already solid: CSRF-protected consent, exacthash_equals()redirect_uri matching, HTTPS-only redirect URIs, mandatory PKCE (S256), no auto-approve path, active-user/active-portal re-checked at both the GET and POST steps.OAuthClientRegistrar::register()hardcodesscope = 'mcp'for every registrant today, and a token'sresourceclaim (RFC 8707) is stored/echoed but "not validated against a registry (none exists, kept deliberately minimal)" - i.e. any registered client can in principle be presented at any future resource server.Proposed changes
Per-resource-server client allowlisting. Let each protected resource server (not just
/mcp) maintain its own admin-curated list of whichclient_ids it accepts tokens for, instead of implicitly trusting any registered client for any resource.DB impact: one new JSON column on the existing
oauth_clienttable (allowed_resources), matching howredirectUris/grantTypes/responseTypesare already stored.Generalize
scopebeyond the hardcoded'mcp'value.OAuthClient.scopealready exists as a column;OAuthClientRegistrar::register()just needs to stop hardcoding it and instead validate/store whatever the registration requests against a real allowed-scopes list.DB impact: none - column already exists.
Consent screen: show client registration age. Cheap phishing deterrent: surface
OAuthClient.createdAton the consent screen ("this application was registered N days ago") so a brand-new client reads differently from a long-established one.DB impact: none -
createdAtalready exists and is never deleted (OAuthGarbageCollectorCommandonly revokes clients unused 30+ days, it doesn't delete the row).Consent screen: durable "have you approved this app before?" signal. A
user_id+client_idhistory table decoupled from the OAuth token tables, sinceOAuthGarbageCollectorCommanddeliberately prunes access tokens (7 days after expiry/revocation) and refresh tokens (30 days) to bound row growth - "first time ever" can't be reliably answered from those tables beyond that window.DB impact: yes: one small new table (
user_id,client_id,first_granted_at), intentionally never pruned by the GC job.software_statementsupport (RFC 7591). Accept an optional signed JWT from a trusted issuer at registration time, verified against a configured public key, as a stronger identity signal than self-asserted metadata. Only worth doing once there's an actual second trusted issuer to verify against.DB impact: Needed only because we also want to persist/display which clients were cryptographically verified (a nullable
verifiedIssuercolumn).Anomaly monitoring. Alert when one client rapidly accumulates authorizations from many distinct users in a short window - a mass-phishing signature.
DB impact: none for live detection (existing
OAuthAccessToken.client_id/user_id/created_atcovers a recent-burst query within the 7-day retention window). Needed only for durable, queryable alert history rather than a live query/notification.Database impact summary
scopesoftware_statementverificationA new setting
security.dynamic_client_registrationof type true/false should be added (defaults to false) to allow admins to enable this feature (otherwise it remains locked on MCP, if enabled).Non-goals
client_credentialsgrant: evaluated and explicitly rejected for server-to-server integrations; those should use aUserApiKey-based static credential instead (see thWordPress storefront plugin integration plan).