PluginProbe
Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF / 2.3.3
Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF v2.3.3
2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.9 2.2.8 trunk 1.10 1.3.3 1.3.4 1.3.5 1.3.5.1 1.3.5.2 1.3.6 1.3.6.1 1.4 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.5 All 103 releases
imagify / vendor / wp-media / mcp-oauth / inc / Auth / AuthorizeEndpoint.php

AuthorizeEndpoint.php in Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF 2.3.3, at vendor/wp-media/mcp-oauth/inc/Auth/AuthorizeEndpoint.php

276 lines 10.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Authorization Endpoint.
4 *
5 * Handles GET /oauth/authorize — validates the PKCE parameters, stores the
6 * challenge in a 60-second transient, then routes the browser onward: a
7 * user with an existing WordPress session is redirected straight to
8 * /oauth/authorize-callback; otherwise the browser goes to the WordPress
9 * login form first, and WordPress delivers the user back to
10 * /oauth/authorize-callback after a successful login, where the auth code
11 * is issued.
12 */
13
14 declare(strict_types=1);
15
16 namespace WPMedia\MCP\OAuth\Auth;
17
18 use WPMedia\MCP\OAuth\Logging\McpLogger;
19
20 /**
21 * Authorize Endpoint.
22 */
23 class AuthorizeEndpoint {
24 /**
25 * Transient TTL for the state parameter (seconds).
26 */
27 const STATE_TTL = 60;
28
29 /**
30 * CIMD resolver used to dereference and validate the client_id URL.
31 *
32 * @var CimdResolver
33 */
34 private CimdResolver $resolver;
35
36 /**
37 * Constructor.
38 *
39 * @param CimdResolver $resolver CIMD resolver.
40 */
41 public function __construct( CimdResolver $resolver ) {
42 $this->resolver = $resolver;
43 }
44
45 /**
46 * Handle an authorization request.
47 *
48 * @return void
49 */
50 public function handle_request(): void {
51 // phpcs:disable WordPress.Security.NonceVerification.Recommended -- OAuth 2.1 authorization request from an external client; CSRF protection is provided by the state parameter and PKCE, not a WP nonce.
52 $client_id = esc_url_raw( wp_unslash( $_GET['client_id'] ?? '' ) );
53 $redirect_uri = esc_url_raw( wp_unslash( $_GET['redirect_uri'] ?? '' ) );
54 $response_type = sanitize_text_field( wp_unslash( $_GET['response_type'] ?? '' ) );
55 $code_challenge = sanitize_text_field( wp_unslash( $_GET['code_challenge'] ?? '' ) );
56 $code_challenge_method = sanitize_text_field( wp_unslash( $_GET['code_challenge_method'] ?? '' ) );
57 $state = sanitize_text_field( wp_unslash( $_GET['state'] ?? '' ) );
58 // phpcs:enable WordPress.Security.NonceVerification.Recommended
59
60 McpLogger::log(
61 'AUTHORIZE',
62 'authorization request received',
63 [
64 'response_type' => $response_type,
65 'client_id' => $client_id,
66 'redirect_uri' => $redirect_uri,
67 'code_challenge_method' => $code_challenge_method,
68 'has_code_challenge' => '' !== $code_challenge ? 'yes' : 'no',
69 'has_state' => '' !== $state ? 'yes' : 'no',
70 'remote_addr' => isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '',
71 ]
72 );
73
74 // Validate the client and redirect_uri BEFORE using redirect_uri in any redirect.
75 // Per OAuth 2.1 §7.5.2 and RFC 6749 §10.15, the AS MUST NOT redirect to a URI
76 // that has not been positively validated against a registered client.
77
78 if ( '' === $client_id ) {
79 McpLogger::log( 'AUTHORIZE', 'rejected: missing client_id' );
80 wp_die( esc_html__( 'client_id is required.', 'mcp-oauth' ), esc_html__( 'OAuth Error', 'mcp-oauth' ), [ 'response' => 400 ] );
81 }
82
83 $client = $this->resolver->resolve( $client_id );
84 if ( null === $client ) {
85 McpLogger::log( 'AUTHORIZE', 'rejected: client_id could not be resolved via CIMD', [ 'client_id' => $client_id ] );
86 wp_die( esc_html__( 'Unknown OAuth client.', 'mcp-oauth' ), esc_html__( 'OAuth Error', 'mcp-oauth' ), [ 'response' => 400 ] );
87 }
88
89 if ( empty( $client['verified'] ) ) {
90 McpLogger::log( 'AUTHORIZE', 'rejected: client not a verified publisher', [ 'client_id' => $client_id ] );
91 wp_die( esc_html__( 'This OAuth client is not a verified publisher.', 'mcp-oauth' ), esc_html__( 'OAuth Error', 'mcp-oauth' ), [ 'response' => 400 ] );
92 }
93
94 if ( '' === $redirect_uri ) {
95 McpLogger::log( 'AUTHORIZE', 'rejected: missing redirect_uri' );
96 wp_die( esc_html__( 'redirect_uri is required.', 'mcp-oauth' ), esc_html__( 'OAuth Error', 'mcp-oauth' ), [ 'response' => 400 ] );
97 }
98
99 if ( ! $this->redirect_uri_matches( $redirect_uri, $client['redirect_uris'] ) ) {
100 McpLogger::log(
101 'AUTHORIZE',
102 'rejected: redirect_uri mismatch',
103 [
104 'provided' => $redirect_uri,
105 'registered' => $client['redirect_uris'],
106 ]
107 );
108 wp_die( esc_html__( 'redirect_uri does not match registered value.', 'mcp-oauth' ), esc_html__( 'OAuth Error', 'mcp-oauth' ), [ 'response' => 400 ] );
109 }
110
111 // redirect_uri is now validated — remaining errors may safely redirect to it.
112
113 if ( 'code' !== $response_type ) {
114 McpLogger::log( 'AUTHORIZE', 'rejected: unsupported response_type', [ 'response_type' => $response_type ] );
115 $this->send_error( $redirect_uri, 'unsupported_response_type', $state );
116 return;
117 }
118
119 if ( '' === $code_challenge || 'S256' !== $code_challenge_method ) {
120 McpLogger::log(
121 'AUTHORIZE',
122 'rejected: missing or invalid PKCE params',
123 [
124 'has_code_challenge' => '' !== $code_challenge ? 'yes' : 'no',
125 'code_challenge_method' => $code_challenge_method,
126 ]
127 );
128 $this->send_error( $redirect_uri, 'invalid_request', $state );
129 return;
130 }
131
132 // OAuth 2.1 §4.1.1 requires state; reject rather than generate silently.
133 // A server-generated state never reaches the client before the redirect,
134 // so the client cannot validate it on return — providing no CSRF protection.
135 if ( '' === $state ) {
136 McpLogger::log( 'AUTHORIZE', 'rejected: state parameter is required' );
137 $this->send_error( $redirect_uri, 'invalid_request', '' );
138 return;
139 }
140
141 // Persist the validated client display data alongside the PKCE state so the
142 // consent screen can be rendered after login without a second CIMD fetch.
143 set_transient(
144 'mcp_oauth_state_' . $state,
145 [
146 'client_id' => $client_id,
147 'client_name' => (string) ( $client['client_name'] ?? '' ),
148 'client_uri' => (string) ( $client['client_uri'] ?? '' ),
149 // Already guaranteed truthy - the 'client not a verified publisher' guard above exits otherwise.
150 'verified' => true,
151 'publisher' => (string) ( $client['publisher'] ?? '' ),
152 'redirect_uri' => $redirect_uri,
153 'code_challenge' => $code_challenge,
154 'code_challenge_method' => $code_challenge_method,
155 'state' => $state,
156 ],
157 self::STATE_TTL
158 );
159
160 // home_url(): the callback is a rewrite endpoint served from the Site Address,
161 // so it must match home_url() and not get_site_url() on split-directory installs.
162 $callback_url = add_query_arg( 'state', rawurlencode( $state ), home_url( '/oauth/authorize-callback' ) );
163
164 if ( is_user_logged_in() ) {
165 McpLogger::log(
166 'AUTHORIZE',
167 'existing session, skipping login',
168 [
169 'state' => $state,
170 'callback_url' => $callback_url,
171 ]
172 );
173 wp_redirect( $callback_url ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- server-constructed home_url() target.
174 exit;
175 }
176
177 $login_url = wp_login_url( $callback_url );
178
179 McpLogger::log(
180 'AUTHORIZE',
181 'redirecting to login',
182 [
183 'state' => $state,
184 'callback_url' => $callback_url,
185 ]
186 );
187
188 wp_redirect( $login_url ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect
189 exit;
190 }
191
192 /**
193 * Determine whether a provided redirect_uri matches a registered one.
194 *
195 * Non-loopback clients require an exact match. Loopback clients (native
196 * apps per RFC 8252) are matched port-agnostically: the ephemeral port
197 * varies per session, so scheme, host, and path must match but the port is
198 * ignored. The exemption is constrained to the literal loopback hosts over
199 * http so it cannot widen open-redirect exposure for normal clients.
200 *
201 * @param string $provided The redirect_uri supplied in the request.
202 * @param string[] $registered The redirect URIs from the client metadata.
203 * @return bool
204 */
205 private function redirect_uri_matches( string $provided, array $registered ): bool {
206 if ( in_array( $provided, $registered, true ) ) {
207 return true;
208 }
209
210 $provided_parts = wp_parse_url( $provided );
211 if ( ! is_array( $provided_parts ) || ! $this->is_loopback( $provided_parts ) ) {
212 return false;
213 }
214
215 foreach ( $registered as $candidate ) {
216 $candidate_parts = wp_parse_url( (string) $candidate );
217 if ( ! is_array( $candidate_parts ) || ! $this->is_loopback( $candidate_parts ) ) {
218 continue;
219 }
220
221 if (
222 ( $provided_parts['scheme'] ?? '' ) === ( $candidate_parts['scheme'] ?? '' )
223 && ( $provided_parts['host'] ?? '' ) === ( $candidate_parts['host'] ?? '' )
224 && ( $provided_parts['path'] ?? '' ) === ( $candidate_parts['path'] ?? '' )
225 ) {
226 return true;
227 }
228 }
229
230 return false;
231 }
232
233 /**
234 * Whether a parsed URL points at a loopback address over http.
235 *
236 * Covers IPv4 (127.0.0.1), hostname (localhost), and IPv6 (::1) loopback
237 * addresses per RFC 8252 §8.3. Only plain HTTP is permitted — HTTPS
238 * loopback is not exempted to avoid widening open-redirect exposure for
239 * normal (non-native-app) clients. wp_parse_url() strips the brackets
240 * from IPv6 literals, so the host value is '::1', not '[::1]'.
241 *
242 * @param array<string, mixed> $parts Parsed URL components from wp_parse_url().
243 * @return bool
244 */
245 private function is_loopback( array $parts ): bool {
246 $scheme = (string) ( $parts['scheme'] ?? '' );
247 $host = (string) ( $parts['host'] ?? '' );
248
249 return 'http' === $scheme && in_array( $host, [ '127.0.0.1', 'localhost', '::1' ], true );
250 }
251
252 /**
253 * Redirect the client to redirect_uri with an error parameter.
254 *
255 * @param string $redirect_uri Destination URI (may be empty on early failure).
256 * @param string $error OAuth error code.
257 * @param string $state State token echoed back to the client.
258 * @return void
259 */
260 private function send_error( string $redirect_uri, string $error, string $state ): void {
261 if ( '' !== $redirect_uri ) {
262 $params = [
263 'error' => $error,
264 'iss' => home_url(),
265 ];
266 if ( '' !== $state ) {
267 $params['state'] = $state;
268 }
269 wp_redirect( add_query_arg( $params, $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- redirecting to the client's own registered redirect_uri, already validated against the CIMD allowlist; not a same-site redirect.
270 exit;
271 }
272
273 wp_die( esc_html( $error ), esc_html__( 'OAuth Error', 'mcp-oauth' ), [ 'response' => 400 ] );
274 }
275 }
276