Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
43.62% covered (danger)
43.62%
41 / 94
44.44% covered (danger)
44.44%
4 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
McpValidation
43.62% covered (danger)
43.62%
41 / 94
44.44% covered (danger)
44.44%
4 / 9
517.21
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_authenticated
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 get_authorization_header
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 extract_bearer_token
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 peek_payload
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
4.25
 is_valid_token
18.75% covered (danger)
18.75%
6 / 32
0.00% covered (danger)
0.00%
0 / 1
306.74
 normalize_pem_key
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_public_key
62.50% covered (warning)
62.50%
10 / 16
0.00% covered (danger)
0.00%
0 / 1
11.38
 set_admin_authentication
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3declare( strict_types=1 );
4
5namespace BLU\Validation;
6
7use BLU\Validation\HiiveProductVerifier;
8use Firebase\JWT\JWT;
9use Firebase\JWT\Key;
10
11/**
12 * Validation class for Blu MCP.
13 */
14class McpValidation {
15
16    /**
17     * Bearer token pattern.
18     *
19     * @var string
20     */
21    private const BEARER_TOKEN_PATTERN = '/^Bearer\s+(\S+)$/i';
22
23    /**
24     * URL to fetch the public key for JWT validation.
25     *
26     * @var string
27     */
28    private const CF_UJWT_PUBLIC_KEY_URL = 'https://cdn.hiive.space/jwt-public-key.pem';
29
30    /**
31     * URL to fetch the staging public key for JWT validation (aud: qa).
32     *
33     * @var string
34     */
35    private const CF_UJWT_PUBLIC_KEY_STAGING_URL = 'https://cdn.hiive.space/jwt-public-key-staging.pem';
36
37    /**
38     * The request object.
39     *
40     * @var \WP_REST_Request
41     */
42    private $request;
43
44    /**
45     * Initializes the class
46     *
47     * @param \WP_REST_Request $request The request object.
48     *
49     * @return void
50     */
51    public function __construct( \WP_REST_Request $request ) {
52        $this->request = $request;
53    }
54
55    /**
56     * Check if the request is authenticated.
57     *
58     * @throws \Exception If authentication fails.
59     * @return bool True if authenticated, false if not.
60     */
61    public function is_authenticated(): bool {
62        try {
63            // If already logged in as admin, allow.
64            if ( is_user_logged_in() && current_user_can( 'manage_options' ) ) {
65                return true;
66            }
67
68            // Otherwise require JWT in the Authorization header.
69            $auth_header = $this->get_authorization_header();
70
71            // Bail early if no auth header is present.
72            if ( empty( $auth_header ) ) {
73                throw new \Exception( 'Authorization header is missing.' );
74            }
75
76            // Extract the token from the auth header.
77            $token = $this->extract_bearer_token( $auth_header );
78
79            // Bail early if no token is present.
80            if ( empty( $token ) ) {
81                throw new \Exception( 'Bearer token is missing.' );
82            }
83
84            // Validate JWT (signature, claims, expiry) and verify product access via Hiive.
85            return $this->is_valid_token( $token );
86
87        } catch ( \Throwable $e ) {
88            return false;
89        }
90    }
91
92    /**
93     * Get Authorization header from request.
94     *
95     * @return string|null
96     */
97    private function get_authorization_header(): ?string {
98        return $this->request->get_header( 'Authorization' );
99    }
100
101    /**
102     * Extract the Bearer token from the authorization header.
103     *
104     * @param string $auth_header Authorization header value.
105     *
106     * @return string|null Token if found, null otherwise.
107     */
108    private function extract_bearer_token( string $auth_header ): ?string {
109        if ( preg_match( self::BEARER_TOKEN_PATTERN, $auth_header, $matches ) ) {
110            return $matches[1];
111        }
112
113        return null;
114    }
115
116    /**
117     * Peek at the JWT payload without verifying the signature.
118     * Used only for key choice (aud) and early expired check (exp); never to accept a token.
119     *
120     * @param string $token The JWT token.
121     *
122     * @return object|null Payload object with aud and exp, or null on failure.
123     */
124    private function peek_payload( string $token ): ?object {
125        // Decode the payload (middle segment) without verifying the signature.
126        $segments = explode( '.', $token );
127        if ( count( $segments ) !== 3 ) {
128            return null;
129        }
130
131        $payload_b64url = $segments[1];
132        $payload_b64    = strtr( $payload_b64url, '-_', '+/' );
133        $payload_raw    = base64_decode( $payload_b64, true );
134        if ( false === $payload_raw ) {
135            return null;
136        }
137
138        $payload = json_decode( $payload_raw );
139        if ( ! is_object( $payload ) ) {
140            return null;
141        }
142
143        return $payload;
144    }
145
146    /**
147     * Validate the JWT token.
148     *
149     * @param string $token The JWT token to validate.
150     *
151     * @return bool True if valid, false otherwise.
152     *
153     * @throws \Exception If token validation fails.
154     */
155    private function is_valid_token( string $token ): bool {
156
157        // Bail early if the token is not in JWT format.
158        if ( strpos( $token, '.' ) === false ) {
159            throw new \Exception( 'Invalid JWT token.' );
160        }
161
162        $peeked = $this->peek_payload( $token );
163
164        // Early exit for expired tokens (no key fetch, no Hiive).
165        if ( null !== $peeked && isset( $peeked->exp ) && is_numeric( $peeked->exp ) && (int) $peeked->exp < time() ) {
166            throw new \Exception( 'Token validation failed. The token has expired.' );
167        }
168
169        // Early exit for not-yet-valid tokens (nbf).
170        if ( null !== $peeked && isset( $peeked->nbf ) && is_numeric( $peeked->nbf ) && (int) $peeked->nbf > time() ) {
171            throw new \Exception( 'Token validation failed. The token is not yet valid.' );
172        }
173
174        // Choose key by audience: QA tokens (aud: qa) use staging key; production uses production key.
175        $use_staging = ( null !== $peeked && isset( $peeked->aud ) && 'qa' === $peeked->aud );
176        $public_key  = $this->get_public_key( $use_staging );
177
178        // Verify signature and decode claims.
179        $decoded = JWT::decode( $token, new Key( $public_key, 'RS256' ) );
180
181        $user_id = null;
182
183        if ( ! isset( $decoded->aud ) ) {
184            throw new \Exception( 'Token validation failed. The audience is invalid.' );
185        }
186
187        if ( ! isset( $decoded->iss ) || 'jarvis-jwt' !== $decoded->iss ) {
188            throw new \Exception( 'Token validation failed. The iss is invalid.' );
189        }
190
191        // Extract user ID: prefer act.sub (acting user, e.g. "urn:jarvis:bluehost:user:549716553")
192        // over top-level sub (account, e.g. "urn:jarvis:bluehost:account:152665891").
193        $sub_source = null;
194        if ( isset( $decoded->act, $decoded->act->sub ) && is_string( $decoded->act->sub ) ) {
195            $sub_source = $decoded->act->sub;
196        } elseif ( isset( $decoded->sub ) && is_string( $decoded->sub ) ) {
197            $sub_source = $decoded->sub;
198        }
199
200        if ( null === $sub_source ) {
201            throw new \Exception( 'Token validation failed. The sub claim is missing.' );
202        }
203
204        $sub_parts = explode( ':', $sub_source );
205        if ( ! empty( $sub_parts ) ) {
206            $user_id = end( $sub_parts );
207        }
208
209        if ( empty( $user_id ) ) {
210            throw new \Exception( 'Token validation failed. The user ID is missing.' );
211        }
212
213        // Verify product access with Hiive (staging for QA tokens, production otherwise).
214        $response = HiiveProductVerifier::verify_product_access( $token, $user_id, $decoded );
215
216        if ( true !== $response ) {
217            throw new \Exception( 'Token validation failed. The product access is invalid.' );
218        }
219
220        // Set WordPress current user to an admin so the request has the required capabilities.
221        $this->set_admin_authentication();
222
223        return true;
224    }
225
226    /**
227     * Normalize a PEM key so OpenSSL accepts it (e.g. convert literal \n to newlines).
228     *
229     * @param string $key Raw key content, possibly with escaped newlines.
230     *
231     * @return string Normalized PEM key.
232     */
233    private function normalize_pem_key( string $key ): string {
234        return trim( str_replace( array( "\\n", "\\r" ), array( "\n", "\r" ), $key ) );
235    }
236
237    /**
238     * Get the public key for JWT validation.
239     *
240     * @param bool $use_staging True to use staging key (aud: qa), false for production.
241     *
242     * @return string
243     *
244     * @throws \Exception If fetching the public key fails.
245     */
246    private function get_public_key( bool $use_staging = false ): string {
247        $transient_key = $use_staging ? 'blu_jwt_public_key_staging' : 'blu_jwt_public_key';
248        $url           = $use_staging ? self::CF_UJWT_PUBLIC_KEY_STAGING_URL : self::CF_UJWT_PUBLIC_KEY_URL;
249        $filter_name   = $use_staging ? 'blu_jwt_public_key_staging' : 'blu_jwt_public_key';
250
251        // Use cached key when available to avoid repeated remote fetches.
252        $public_key = get_transient( $transient_key );
253
254        if ( false === $public_key ) {
255            try {
256                $response = wp_remote_get( $url );
257
258                if ( is_wp_error( $response ) ) {
259                    throw new \Exception( 'Failed to fetch public key: ' . $response->get_error_message() );
260                }
261
262                $body = wp_remote_retrieve_body( $response );
263
264                if ( empty( $body ) ) {
265                    throw new \Exception( 'Public key response body is empty.' );
266                }
267
268                $public_key = $this->normalize_pem_key( $body );
269
270                // Cache the key for 1 hour.
271                set_transient( $transient_key, $public_key, HOUR_IN_SECONDS );
272
273            } catch ( \Exception $e ) {
274                throw new \Exception( 'Failed to fetch public key: ' . esc_html( $e->getMessage() ) );
275            }
276        }
277
278        return apply_filters( $filter_name, $this->normalize_pem_key( $public_key ) );
279    }
280
281    /**
282     * Set the current user to an administrator for authentication.
283     *
284     * @return void
285     *
286     * @throws \Exception If no valid admin user is found.
287     */
288    private function set_admin_authentication(): void {
289        // Use cached admin user when valid; otherwise resolve the first administrator.
290        $admin_user    = get_transient( 'nfd_blu_mcp_user' );
291        $valid_user_id = false;
292        if ( $admin_user ) {
293            if ( user_can( $admin_user, 'manage_options' ) ) {
294                $valid_user_id = true;
295            }
296        }
297
298        if ( ! $valid_user_id ) {
299            $args       = array(
300                'role'   => 'administrator',
301                'fields' => 'ID',
302                'number' => 1,
303            );
304            $admin_user = get_users( $args );
305
306            if ( empty( $admin_user ) ) {
307                throw new \Exception( 'No user found for authentication.' );
308            }
309
310            $admin_user = $admin_user[0];
311            set_transient( 'nfd_blu_mcp_user', $admin_user, 2 * HOUR_IN_SECONDS );
312        }
313        wp_set_current_user( $admin_user );
314    }
315}