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 / AuthManager.php

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

196 lines 5.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Resolves a presented credential to an acting WordPress user + access level
4 * (spec 044, FR-016–FR-019, FR-025).
5 *
6 * ## Why identity is established here and not in a global filter
7 *
8 * This runs from the route's `permission_callback`, which fires before the
9 * route callback — so `wp_set_current_user()` lands before any capability's own
10 * permission rule is evaluated and before any work begins (FR-016). A global
11 * `determine_current_user` filter would also work but would run on EVERY request
12 * to the site to serve one route.
13 *
14 * The ordering is load-bearing well beyond the permission checks: 042's
15 * Support\AjaxLoopbackDispatcher mints its FSI cookie session from
16 * `get_current_user_id()` (AjaxLoopbackDispatcher.php:110). If the acting user
17 * were not established by then, full-site import could not start at all — which
18 * is why the reference implementation's model (tokens with no user) could not
19 * simply be copied.
20 *
21 * @package Templately\Modules\McpServer\Auth
22 */
23
24 namespace Templately\Modules\McpServer\Auth;
25
26 use Templately\Modules\McpServer\Auth\OAuth\RecordStore;
27 use Templately\Modules\McpCore\Registry\ToolDescriptor;
28 use Templately\Modules\McpCore\Support\Permissions;
29
30 class AuthManager {
31
32 /** @var array|null Resolved context for this request. */
33 private static $context = null;
34
35 /**
36 * Resolve the request's credential.
37 *
38 * @return array|null {credential_id, user_id, access_level} or null.
39 */
40 public static function resolve(): ?array {
41 if ( null !== self::$context ) {
42 return self::$context;
43 }
44
45 $secret = self::bearer_token();
46
47 if ( '' !== $secret ) {
48 $context = self::resolve_secret( $secret );
49
50 if ( null === $context ) {
51 FailedAuthLimiter::record_failure();
52
53 return null;
54 }
55
56 FailedAuthLimiter::clear();
57 wp_set_current_user( $context['user_id'] );
58 self::$context = $context;
59
60 return $context;
61 }
62
63 // No bearer credential — fall back to whatever core already
64 // authenticated (Application Passwords keep working unchanged, FR-025).
65 $user_id = get_current_user_id();
66
67 if ( $user_id > 0 && Permissions::can_use_abilities() ) {
68 self::$context = [
69 'credential_id' => null,
70 'user_id' => $user_id,
71 'access_level' => ToolDescriptor::ACCESS_FULL,
72 ];
73
74 return self::$context;
75 }
76
77 return null;
78 }
79
80 /**
81 * Map a secret to a context, checking direct credentials then delegated ones.
82 *
83 * @param string $secret
84 * @return array|null
85 */
86 private static function resolve_secret( string $secret ): ?array {
87 $record = Credentials::find_by_secret( $secret );
88
89 if ( null !== $record ) {
90 if ( ! self::user_still_eligible( (int) $record['user_id'] ) ) {
91 return null;
92 }
93
94 Credentials::touch( (string) $record['id'] );
95
96 return [
97 'credential_id' => (string) $record['id'],
98 'user_id' => (int) $record['user_id'],
99 'access_level' => Credentials::normalize_level( (string) $record['access_level'] ),
100 ];
101 }
102
103 if ( ! class_exists( RecordStore::class ) ) {
104 return null;
105 }
106
107 $issued = RecordStore::find_access_token( $secret );
108
109 if ( null === $issued || ! self::user_still_eligible( (int) $issued['user_id'] ) ) {
110 return null;
111 }
112
113 return [
114 'credential_id' => (string) ( $issued['client_id'] ?? 'oauth' ),
115 'user_id' => (int) $issued['user_id'],
116 'access_level' => Credentials::normalize_level( (string) $issued['access_level'] ),
117 ];
118 }
119
120 /**
121 * A credential stops granting access the moment its bound account is deleted
122 * or drops below the required capability (FR-018) — the credential itself
123 * need not be revoked for access to end.
124 *
125 * @param int $user_id
126 * @return bool
127 */
128 private static function user_still_eligible( int $user_id ): bool {
129 if ( $user_id <= 0 ) {
130 return false;
131 }
132
133 $user = get_userdata( $user_id );
134
135 if ( ! $user ) {
136 return false;
137 }
138
139 return user_can( $user, 'manage_options' );
140 }
141
142 /**
143 * Header only — never a path or query parameter (FR-022). A bearer secret in
144 * a URL lands in access logs, proxy logs, browser history and `Referer`.
145 *
146 * @return string
147 */
148 private static function bearer_token(): string {
149 $header = '';
150
151 if ( ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) {
152 $header = sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) );
153 } elseif ( ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) {
154 // Apache strips Authorization unless explicitly passed through.
155 $header = sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) );
156 }
157
158 if ( '' === $header || 0 !== stripos( $header, 'bearer ' ) ) {
159 return '';
160 }
161
162 return trim( substr( $header, 7 ) );
163 }
164
165 /**
166 * The WWW-Authenticate challenge — how a URL-only client discovers where to
167 * authenticate (FR-029).
168 *
169 * @return string
170 */
171 public static function challenge_header(): string {
172 // The RESOURCE-SPECIFIC metadata URL (RFC 9728 §3.1: the resource's path
173 // is appended to the well-known prefix), not the bare one.
174 //
175 // This header is the authoritative pointer — it is how a client that
176 // holds only a site address finds the auth flow, and it is followed in
177 // preference to guessing. Aiming it at the bare path aimed clients at a
178 // slot shared with every other OAuth-serving plugin on the site, which is
179 // how ChatGPT ended up being handed a different plugin's authorization
180 // endpoint for Templately's own resource.
181 $path = (string) wp_parse_url( RecordStore::audience(), PHP_URL_PATH );
182
183 return sprintf(
184 'Bearer resource_metadata="%s"',
185 esc_url_raw( home_url( '/.well-known/oauth-protected-resource' . $path ) )
186 );
187 }
188
189 /**
190 * Test seam.
191 */
192 public static function reset(): void {
193 self::$context = null;
194 }
195 }
196