Authenticate + Authorize WP REST API request before built-in WP JSON Schema Payload Validation?

Alright, guess I figured it all out:

Has WP Core been updated to do JSON Schema – based request argument validation automatically now?

It seems that that’s the case, yes. If you provide a proper JSON schema, as shown in the example, when using register_rest_route, payload validation is executed automatically, without the provision of a validate_callback for your args.

Does payload validation before calling your permission_callback?

Apparently that’s the case too. Consequently, if you do not rely on wordpress cookies for your custom WP REST API calls, unauthorized clients are actually able to get validation feedback, which imho is not always ideal.

…if WordPress does any kind of REST authentication by default?

It seems that this is not the case if you do not rely on a client being logged in, as wordpress seems to use cookies for authentication, and their built-in authorization using WP nonces apparently does not work without cookies. Meaning that, if you do not want to rely on wordpress cookies, maybe if you have your own login / user / session logic, you must also implement your own authentication mechanism. That’s my assumption, as no authentication step seems to happen by default via REST requests, if you do not use wordpress cookies.

I thus searched for a way to implement an endpoint-specific authentication, and figured that using the rest_pre_dispatch filter hook may be the best option to do so. From the docs linked above, what the filter does is:

Allow hijacking the request before dispatching by returning a
non-empty. The returned value will be used to serve the request
instead.

So you can do something like this in your main plugin file, for example:

/**
 * Authentication
 *
 * This section sets up the authentication for the different endpoints.
 */

add_filter(
    'rest_pre_dispatch',
    function (
        $result,
        WP_REST_Server $server,
        WP_REST_Request $request
    ) {
        
        // route value is full endpoint path
        $requested_route = $request->get_route();
        
        // Assuming you want to authenticate requests of a given namespace
        if ( str_starts_with($requested_route, '/yourapinamespace/v1') ) {
            
            // May change the number here, 20 matches the position after v1 
            $resource = substr($requested_route,20);
            $request_method = $request->get_method();
            
            switch ( $resource ) {
                
                case '/purchases':
                    
                    return match ($request_method) {
                        
                        'POST' => Authenticate::for_purchase($request),
                        
                        // Do not allow unauthenticated requests
                        default => new WP_Error(
                            401,
                            esc_html__('Authentication failed!','my-txt-domain'),
                            [
                                'status' => 401
                            ]
                        )
                        
                    };
                
                default:
                    
                    return null;
                
            }
            
        }
        
        return null;
        
    },
    10,
    3
);

If everything’s fine, return null in your callback, and the request will get through in the usual way. Otherwise, the request will get interrupted immediately, and the WP_Error instance you return will be used for the response back to the client.

I prefer this solution, as it acts immediately, and avoids any unnecessary roundtrips in case of unauthenticated requests. Remember though that payload validation has not occurred at the stage of the rest_pre_dispatch filter. So make sure you won’t treat your request arguments as if they were validated within your authentication callbacks.

Finally, remember to also implement your authorization after this, and automatically execute it after successful authentication. I see two options here:

  1. You can still do so by providing the according callback as a permission_callback when registering your endpoint via register_rest_route. Using a permission_callback however means that your endpoint will execute an authorization check after the payload validation; hence authenticated but unauthorized clients may get information about payload validation problems.
  2. The second solution would be to implement your own nonce check by calling check_ajax_referer() or similar within your Authenticate::for_purchase($request), and simply set the permission_callback when registering your endpoint to __return_true.