PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / 3.8.0
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! v3.8.0
3.8.0 3.7.5 3.7.4 3.7.3 3.7.2 1-final 3.7.1 3.7.0 3.6.8 3.6.7 3.6.6 3.6.5 3.6.4 3.6.3 3.6.2 3.6.1 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 All 112 releases
templately / modules / mcp-server / Auth / Credentials.php

Credentials.php in Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! 3.8.0, at modules/mcp-server/Auth/Credentials.php

239 lines 6.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Connection credentials for the built-in MCP server (spec 044, FR-020–FR-027).
4 *
5 * ## Storage — and why NOT Options
6 *
7 * Plain `get_option`/`update_option`, non-autoloaded. On multisite each site has
8 * its own options table, so per-site scoping (FR-039a) holds by construction —
9 * a credential minted on one site cannot authenticate against another.
10 *
11 * Deliberately NOT `Templately\Utils\Options`: that class switches to
12 * `get_user_option`/`update_user_option` on multisite (Options.php:177-195),
13 * scoped by an `_is_global()` flag. That is the documented cause of the defect
14 * where a value written from one context is read from a different scope — the
15 * project's own CLAUDE.md warns that setting the API key from WP-CLI "can land
16 * in a different scope than the read". Connection state must not inherit it.
17 *
18 * Also NOT `get_site_option`: that is network-wide, which would make one
19 * credential authenticate against every site in the network.
20 *
21 * ## Secrets
22 *
23 * Only a SHA-256 hash is stored (FR-021). The plaintext exists exactly once, in
24 * the response to the create call. Presenting the stored hash does not
25 * authenticate, because whatever is presented is itself hashed before
26 * comparison. This corrects the reference implementation, which stores its
27 * pairing token in plaintext while hashing its OAuth tokens.
28 *
29 * @package Templately\Modules\McpServer\Auth
30 */
31
32 namespace Templately\Modules\McpServer\Auth;
33
34 use Templately\Modules\McpCore\Registry\ToolDescriptor;
35
36 class Credentials {
37
38 const OPTION = 'templately_mcp_credentials';
39
40 /** Throttle for last-used writes, so read traffic doesn't write every request. */
41 const LAST_USED_THROTTLE = 60;
42
43 /**
44 * All credential records (never contains a usable secret).
45 *
46 * @return array
47 */
48 public static function all(): array {
49 $records = get_option( self::OPTION, [] );
50
51 return is_array( $records ) ? $records : [];
52 }
53
54 /**
55 * Admin-facing listing — FR-020c. Explicitly drops the hash so it cannot
56 * reach a response body even by accident.
57 *
58 * @return array
59 */
60 public static function list_public(): array {
61 return array_values(
62 array_map(
63 static function ( $record ) {
64 unset( $record['token_hash'] );
65
66 // Resolve the bound account to a name. An administrator needs
67 // to see WHO a credential acts as — a bare user id does not
68 // answer that, and this is the identity every capability's
69 // permission check runs against.
70 $user = ! empty( $record['user_id'] ) ? get_userdata( (int) $record['user_id'] ) : null;
71
72 $record['user_login'] = $user ? $user->user_login : '';
73
74 return $record;
75 },
76 self::all()
77 )
78 );
79 }
80
81 /**
82 * Mint a credential. The returned `secret` is the ONLY time it exists.
83 *
84 * @param string $name Administrator-supplied label.
85 * @param int $user_id Account the credential acts as.
86 * @param string $access_level ToolDescriptor::ACCESS_READ|ACCESS_FULL.
87 * @return array{id:string,secret:string,record:array}
88 */
89 public static function create( string $name, int $user_id, string $access_level ): array {
90 $secret = bin2hex( random_bytes( 32 ) );
91 $id = 'cred_' . bin2hex( random_bytes( 8 ) );
92
93 $record = [
94 'id' => $id,
95 'name' => $name !== '' ? $name : __( 'Untitled connection', 'templately' ),
96 'token_hash' => hash( 'sha256', $secret ),
97 'user_id' => $user_id,
98 'access_level' => self::normalize_level( $access_level ),
99 'created_at' => time(),
100 'last_used_at' => null,
101 ];
102
103 $records = self::all();
104 $records[ $id ] = $record;
105
106 update_option( self::OPTION, $records, 'no' );
107
108 return [ 'id' => $id, 'secret' => $secret, 'record' => $record ];
109 }
110
111 /**
112 * Resolve a presented secret to its record, or null.
113 *
114 * Compares with hash_equals against every record. N is single digits in
115 * practice, and a linear scan keeps the comparison constant-time per record.
116 *
117 * @param string $secret
118 * @return array|null
119 */
120 public static function find_by_secret( string $secret ): ?array {
121 if ( '' === $secret ) {
122 return null;
123 }
124
125 $presented = hash( 'sha256', $secret );
126
127 foreach ( self::all() as $record ) {
128 if ( ! empty( $record['token_hash'] ) && hash_equals( (string) $record['token_hash'], $presented ) ) {
129 return $record;
130 }
131 }
132
133 return null;
134 }
135
136 /**
137 * @param string $id
138 * @param string $access_level
139 * @return bool
140 */
141 public static function set_access_level( string $id, string $access_level ): bool {
142 $records = self::all();
143
144 if ( ! isset( $records[ $id ] ) ) {
145 return false;
146 }
147
148 $records[ $id ]['access_level'] = self::normalize_level( $access_level );
149
150 // Takes effect on the very next request — no client reconfiguration
151 // needed, because the level is read per-request from this record (FR-027).
152 return update_option( self::OPTION, $records, 'no' );
153 }
154
155 /**
156 * Revoke ONE credential. Every other credential keeps working (FR-020b).
157 *
158 * @param string $id
159 * @return bool
160 */
161 public static function revoke( string $id ): bool {
162 $records = self::all();
163
164 if ( ! isset( $records[ $id ] ) ) {
165 return false;
166 }
167
168 unset( $records[ $id ] );
169
170 return update_option( self::OPTION, $records, 'no' );
171 }
172
173 /**
174 * Revoke everything and return the endpoint to inert (FR-024a).
175 * Also clears delegated records — one kill switch covers both credential
176 * systems, so an administrator never has to revoke twice.
177 *
178 * @return void
179 */
180 public static function revoke_all(): void {
181 delete_option( self::OPTION );
182
183 if ( class_exists( OAuth\RecordStore::class ) ) {
184 OAuth\RecordStore::purge_all();
185 }
186 }
187
188 /**
189 * Whether the site holds any credential. While false, every request to the
190 * endpoint is refused — the endpoint is inert until an administrator
191 * explicitly connects (FR-023).
192 *
193 * @return bool
194 */
195 public static function site_has_any(): bool {
196 if ( ! empty( self::all() ) ) {
197 return true;
198 }
199
200 return class_exists( OAuth\RecordStore::class ) && OAuth\RecordStore::has_any();
201 }
202
203 /**
204 * Record use, throttled.
205 *
206 * @param string $id
207 * @return void
208 */
209 public static function touch( string $id ): void {
210 $records = self::all();
211
212 if ( ! isset( $records[ $id ] ) ) {
213 return;
214 }
215
216 $last = (int) ( $records[ $id ]['last_used_at'] ?? 0 );
217
218 if ( ( time() - $last ) < self::LAST_USED_THROTTLE ) {
219 return;
220 }
221
222 $records[ $id ]['last_used_at'] = time();
223
224 update_option( self::OPTION, $records, 'no' );
225 }
226
227 /**
228 * Anything not explicitly "read" is full access — fail closed (FR-026c).
229 *
230 * @param string $level
231 * @return string
232 */
233 public static function normalize_level( string $level ): string {
234 return ToolDescriptor::ACCESS_READ === $level
235 ? ToolDescriptor::ACCESS_READ
236 : ToolDescriptor::ACCESS_FULL;
237 }
238 }
239