Slim 4 - CORS setup

24 Nov 2019

When you implement your first web application (SPA) and deploy the API endpoints on a different hostname (domain), your browser should complain about CORS security policies.

The error message might be something like this:

Access to XMLHttpRequest at '...' from origin '...' has been blocked by CORS policy: 
Response to preflight request doesn't pass access control check: It does not have HTTP ok status.

To allow the browser to access a foreign domain, the API must provide the client with the correct HTTP headers in the response. Before the browser sends the real request a preflight request is sent to the same URL using the http OPTIONS method. The API must answer this options request with the status code 200.

This flowchart describes it well: https://gluu.org/docs/ce/admin-guide/cors/.

Middleware

To send the correct CORS header you first need the following middleware:

File: src/Middleware/CorsMiddleware.php

<?php

namespace App\Middleware;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Routing\RouteContext;

/**
 * CORS middleware.
 */
final class CorsMiddleware implements MiddlewareInterface
{
    /**
     * Invoke middleware.
     *
     * @param ServerRequestInterface $request The request
     * @param RequestHandlerInterface $handler The handler
     *
     * @return ResponseInterface The response
     */
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $routeContext = RouteContext::fromRequest($request);
        $routingResults = $routeContext->getRoutingResults();
        $methods = $routingResults->getAllowedMethods();
        $requestHeaders = $request->getHeaderLine('Access-Control-Request-Headers');

        $response = $handler->handle($request);

        $response = $response->withHeader('Access-Control-Allow-Origin', '*');
        $response = $response->withHeader('Access-Control-Allow-Methods', implode(', ', $methods));
        $response = $response->withHeader('Access-Control-Allow-Headers', $requestHeaders ?: '*');

        // Allow Ajax CORS requests with Authorization header
        $response = $response->withHeader('Access-Control-Allow-Credentials', 'true');

        return $response;
    }
}

Add the CorsMiddlware before the RoutingMiddleware.

// ...

$app->add(\App\Middleware\CorsMiddleware::class); // <--- here

// The RoutingMiddleware should be added after our CORS middleware so routing is performed first
$app->addRoutingMiddleware();

// ...

Routing

An OPTIONS (preflight) request is basically a route like any other and must be added for each route (path) you want to allow.

Example 1: The route /

$app->get('/', \App\Action\HomeAction::class);

// Allow preflight requests for /
$app->options('/', function (Request $request, Response $response): Response {
    return $response;
});

Example 2: The route /example

$app->post('/example', \App\Action\ExampleAction::class);

// Allow preflight requests for /example
$app->options('/example', function (Request $request, Response $response): Response {
    return $response;
});

Example for a route group:

use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Routing\RouteCollectorProxy;

// ...

$app->group('/v2', function (RouteCollectorProxy $group) {
    // Authentication
    $group->post('/login', \App\Action\Authentication\LoginAction::class);

    // Allow preflight requests for /v2/login
    $group->options('/login', function (Request $request, Response $response): Response {
        return $response;
    });

    $group->post('/register', \App\Action\Authentication\RegisterAction::class);

    // Allow preflight requests for /v2/register
    $group->options('/register', function (Request $request, Response $response): Response {
        return $response;
    });

    // and so on...
});

To reduce the routing boilerplate code you could add a simple PreflightAction class.

File: src/Action/PreflightAction.php

<?php

namespace App\Action;

use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as ServerRequest;

final class PreflightAction
{
    public function __invoke(ServerRequest $request, Response $response): Response
    {
        // Do nothing here. Just return the response.
        return $response;
    }
}

Here is an complete routing example with (options) preflight routes:

use App\Action\PreflightAction;
use Slim\Routing\RouteCollectorProxy;

//
// The routes
//
$app->group('/api/v0', function (RouteCollectorProxy $group) {
    $group->get('/users', function (Request $request, Response $response): Response {
        $response->getBody()->write('List all users');

        return $response;
    });

    $group->post('/users', function (Request $request, Response $response): Response {
        // Retrieve the JSON data
        $parameters = (array)$request->getParsedBody();

        // Your code here
        $response->getBody()->write('Create user');

        return $response;
    });

    // Allow preflight requests for /api/v0/users
    // Due to the behaviour of browsers when sending a request,
    // you must add the OPTIONS method.
    $group->options('/users', PreflightAction::class);

    $group->get('/users/{id}', function (Request $request, Response $response, array $arguments): Response {
        $userId = (int)$arguments['id'];
        $response->getBody()->write(sprintf('Get user: %s', $userId));

        return $response;
    });

    $group->delete('/users/{id}', function (Request $request, Response $response, array $arguments): Response {
        $userId = (int)$arguments['id'];
        $response->getBody()->write(sprintf('Delete user: %s', $userId));

        return $response;
    });

    $group->put('/users/{id}', function (Request $request, Response $response, array $arguments): Response {
        // Your code here...
        $userId = (int)$arguments['id'];
        $response->getBody()->write(sprintf('Put user: %s', $userId));

        return $response;
    });

    $group->patch('/users/{id}', function (Request $request, Response $response, array $arguments): Response {
        $userId = (int)$arguments['id'];
        $response->getBody()->write(sprintf('Patch user: %s', $userId));

        return $response;
    });

    // Allow preflight requests for /api/v0/users/{id}
    $group->options('/users/{id}', PreflightAction::class);
});

$app->run();

Test script

<html>

<script src="//ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js"></script>

<script>
$.ajax({
    // change the url here
    url: "http://localhost/api/v0/users",
    type: "GET",
    // Add the Authorization header if needed
    headers: {'Authorization' : 'Bearer 12345'},
    cache: false,
    // contentType: 'application/json',
    dataType: 'json'
}).done(function (data) {
    alert('Successfully');
    console.log(data);
}).fail(function (xhr) {
    var message = 'Server error';
    if (xhr.responseJSON && xhr.responseJSON.error.message) {
       message = xhr.responseJSON.error.message;
    }
    alert(message);
});
</script>
</html>

Read more: Setting up CORS

Comments