dolibarr 25.0.0-alpha
mcpauth.class.php
Go to the documentation of this file.
1<?php
2/* Copyright (C) 2026 Morgan Demoulin <morgan.demoulin@gmail.com>
3 *
4 * This program is free software; you can redistribute it and/or modify
5 * it under the terms of the GNU General Public License as published by
6 * the Free Software Foundation; either version 3 of the License, or
7 * (at your option) any later version.
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
13 *
14 * You should have received a copy of the GNU General Public License
15 * along with this program. If not, see <https://www.gnu.org/licenses/>.
16 * or see https://www.gnu.org/
17 */
18
46{
50 const MODE_USER = 'user';
51
55 const MODE_SHARED = 'shared';
56
60 private $db;
61
65 public $error = '';
66
70 public $httpcode = 401;
71
76 public $userid = 0;
77
81 public $mode = '';
82
89 public $user = null;
90
96 public function __construct($db)
97 {
98 $this->db = $db;
99 }
100
121 public function getCredential($server = null, $get = null)
122 {
123 if ($server === null) {
124 $server = $_SERVER;
125 }
126 if ($get === null) {
127 $get = $_GET; // Keep $_GET here: the key is read before any Dolibarr context exists.
128 }
129
130 $credential = '';
131
132 if (!empty($server['HTTP_DOLAPIKEY'])) {
133 $credential = $server['HTTP_DOLAPIKEY'];
134 }
135
136 if ($credential === '') {
137 $authheader = '';
138 if (!empty($server['HTTP_AUTHORIZATION'])) {
139 $authheader = $server['HTTP_AUTHORIZATION'];
140 } elseif (!empty($server['REDIRECT_HTTP_AUTHORIZATION'])) {
141 $authheader = $server['REDIRECT_HTTP_AUTHORIZATION'];
142 } elseif (function_exists('getallheaders')) {
143 $headers = array_change_key_case(getallheaders(), CASE_LOWER);
144 $authheader = isset($headers['authorization']) ? $headers['authorization'] : '';
145 }
146 $reg = array();
147 if ($authheader !== '' && preg_match('/^Bearer\s+(\S+)$/i', $authheader, $reg)) {
148 $credential = $reg[1];
149 }
150 }
151
152 if ($credential === '' && !empty($server['HTTP_X_API_KEY'])) {
153 $credential = $server['HTTP_X_API_KEY'];
154 }
155
156 // Query-string fallback, required by MCP clients that cannot send a
157 // custom auth header from their connector UI (Claude Desktop "Custom
158 // Connectors" exposes OAuth fields only). Keys sent this way end up in
159 // webserver access logs and possibly in Referer headers, so it is tried
160 // last; administrators relying on it should restrict access at the
161 // webserver level and rotate the key regularly.
162 if ($credential === '') {
163 foreach (array('DOLAPIKEY', 'api_key', 'key') as $param) {
164 if (!empty($get[$param])) {
165 $credential = $get[$param];
166 break;
167 }
168 }
169 }
170
171 return dol_string_nounprintableascii((string) $credential, 1);
172 }
173
185 public function authenticate($server = null, $get = null)
186 {
187 $this->error = '';
188 $this->httpcode = 401;
189 $this->userid = 0;
190 $this->mode = '';
191 $this->user = null;
192
193 $credential = $this->getCredential($server, $get);
194
195 if ($credential === '') {
196 $this->error = 'Missing credentials. Provide a Dolibarr API key with an "Authorization: Bearer <key>" or "DOLAPIKEY: <key>" header.';
197 return -1;
198 }
199
200 // An encrypted key read straight out of the database is not a credential.
201 // Saying so explicitly saves the administrator a long hunt, and the value
202 // is useless to an attacker anyway.
203 if (preg_match('/^dolcrypt:/i', $credential)) {
204 $this->httpcode = 503;
205 $this->error = 'Bad value for the API key. An API key should not start with dolcrypt:';
206 return -1;
207 }
208
209 // Shared server key, kept for setups configured before per-user keys
210 // were accepted. Compared first because it is a single cheap test.
211 $sharedkey = getDolGlobalString('AI_MCP_API_KEY');
212 if ($sharedkey !== '' && hash_equals($sharedkey, $credential)) {
213 $this->mode = self::MODE_SHARED;
214 return 1;
215 }
216
217 $userid = $this->fetchUserIdFromApiKey($credential);
218 if ($userid > 0) {
219 require_once DOL_DOCUMENT_ROOT.'/user/class/user.class.php';
220
221 $tmpuser = new User($this->db);
222 if ($tmpuser->fetch($userid) <= 0) {
223 dol_syslog('[MCP Server] Authentication KO: cannot load user '.$userid, LOG_ERR);
224 $this->error = 'Unauthorized';
225 return -1;
226 }
227 $tmpuser->loadRights();
228
229 // Same gate as every other AI entry point (assistant/index.php,
230 // parse_intent.php, execute_tool.php...). The right is not granted
231 // by default, so enabling the MCP server does not silently turn
232 // every REST API key into an MCP credential: an administrator
233 // still decides who may talk to the assistant.
234 if (!$tmpuser->hasRight('ai', 'assistant', 'use')) {
235 dol_syslog('[MCP Server] Authentication KO: user '.$tmpuser->login.' has no ai/assistant/use permission', LOG_NOTICE);
236 $this->httpcode = 403;
237 $this->error = 'The user owning this API key is not allowed to use the AI assistant';
238 return -1;
239 }
240
241 $this->userid = $userid;
242 $this->user = $tmpuser;
243 $this->mode = self::MODE_USER;
244 return 1;
245 }
246
247 dol_syslog('[MCP Server] Unauthorized access attempt. IP='.(empty($_SERVER['REMOTE_ADDR']) ? 'unknown' : $_SERVER['REMOTE_ADDR']), LOG_WARNING);
248 sleep(1); // Anti brute force protection. Same delay as the REST API uses on a bad key.
249
250 $this->error = 'Unauthorized';
251 return -1;
252 }
253
266 public function getWwwAuthenticateHeader($resourcemetadataurl = '')
267 {
268 $challenge = 'Bearer realm="Dolibarr MCP"';
269 if ($resourcemetadataurl !== '') {
270 $challenge .= ', resource_metadata="'.$resourcemetadataurl.'"';
271 }
272
273 return $challenge;
274 }
275
287 private function fetchUserIdFromApiKey($credential)
288 {
289 if (getDolGlobalString('API_IN_TOKEN_TABLE')) {
290 $sql = "SELECT u.rowid, u.login, u.statut, oat.tokenstring as storedkey";
291 $sql .= " FROM ".$this->db->prefix()."oauth_token as oat";
292 $sql .= " INNER JOIN ".$this->db->prefix()."user as u ON u.rowid = oat.fk_user";
293 $sql .= " WHERE (oat.tokenstring = '".$this->db->escape($credential)."'";
294 $sql .= " OR oat.tokenstring = '".$this->db->escape(dolEncrypt($credential, '', '', 'dolibarr'))."')";
295 $sql .= " AND oat.service = 'dolibarr_rest_api'";
296 } else {
297 $sql = "SELECT u.rowid, u.login, u.statut, u.api_key as storedkey";
298 $sql .= " FROM ".$this->db->prefix()."user as u";
299 $sql .= " WHERE u.api_key = '".$this->db->escape($credential)."'";
300 $sql .= " OR u.api_key = '".$this->db->escape(dolEncrypt($credential, '', '', 'dolibarr'))."'";
301 }
302
303 $resql = $this->db->query($sql);
304 if (!$resql) {
305 dol_syslog('[MCP Server] Authentication query failed: '.$this->db->lasterror(), LOG_ERR);
306 return 0;
307 }
308 if ($this->db->num_rows($resql) != 1) {
309 // Zero rows is a bad key. More than one means two users share a key,
310 // so there is no single identity to run as and the request is refused.
311 return 0;
312 }
313
314 $obj = $this->db->fetch_object($resql);
315 if (empty($obj)) {
316 return 0;
317 }
318
319 // The SQL already matched the key, so this only guards against a match
320 // that did not come from the value presented (a collation that ignores
321 // case or trailing spaces, for instance).
322 if (!hash_equals((string) dolDecrypt($obj->storedkey), $credential)) {
323 dol_syslog('[MCP Server] Authentication KO: key matched user '.$obj->login.' but differs from the stored value', LOG_WARNING);
324 return 0;
325 }
326
327 if (empty($obj->statut)) {
328 dol_syslog('[MCP Server] Authentication KO: user '.$obj->login.' is disabled', LOG_NOTICE);
329 return 0;
330 }
331
332 dol_syslog('[MCP Server] Request authenticated for user '.$obj->login, LOG_DEBUG);
333
334 return (int) $obj->rowid;
335 }
336}
McpAuth Class.
authenticate($server=null, $get=null)
Authenticate the caller.
getCredential($server=null, $get=null)
Extract the credential presented by the caller.
const MODE_SHARED
Credential mode: the shared AI_MCP_API_KEY was presented.
__construct($db)
Constructor.
const MODE_USER
Credential mode: an individual user API key was presented.
getWwwAuthenticateHeader($resourcemetadataurl='')
Value of the WWW-Authenticate header to send with a 401.
fetchUserIdFromApiKey($credential)
Look up the active user owning this API key.
Class to manage Dolibarr users.
dol_string_nounprintableascii($str, $removetabcrlf=1)
Clean a string from all non printable ASCII chars (0x00-0x1F and 0x7F).
getDolGlobalString($key, $default='')
Return a Dolibarr global constant string value.
dol_syslog($message, $level=LOG_INFO, $ident=0, $suffixinfilename='', $restricttologhandler='', $logcontext=null)
Write log message into outputs.
$conf db user
Active Directory does not allow anonymous connections.
Definition repair.php:141
dolDecrypt($chain, $key='', $patterntotest='')
Decode a string with a symmetric encryption.
dolEncrypt($chain, $key='', $ciphering='', $forceseed='', $obfuscationmode='dolcrypt')
Encode a string with a symmetric encryption.