Skip to content

Example: custom authorization ​

Three endpoints protected by authorization classes written for the app: any user, admins only, and a user or a partner API key.

Open the diagram at full size
RequestResponse
GET /api/v1/me without a token401 Missing bearer token
GET /api/v1/me with Bearer bob-token200 {"name":"Bob","roles":["staff"]}
GET /api/v1/report with Bearer bob-token401 Role admin is required
GET /api/v1/report with Bearer alice-token200
GET /api/v1/any/me with X-API-Key: partner-key200, the API key client

The services ​

Each authorization is a separate service with autowired: false, so one class serves several endpoints with different settings.

yaml
application:
	mapping:
		Api: Tomaj\NetteApi\Presenters\*Presenter

parameters:
	# token => user; in a real app this comes from the database
	users:
		alice-token: {name: Alice, roles: [admin, staff]}
		bob-token: {name: Bob, roles: [staff]}
	apiKeys:
		partner-key: '*'

services:
	router: App\RouterFactory::create
	apiPresenter:
		factory: Tomaj\NetteApi\Presenters\ApiPresenter
	- Tomaj\NetteApi\Output\Configurator\EnvConfigurator
	- Tomaj\NetteApi\Error\DefaultErrorHandler
	- Tomaj\NetteApi\Misc\StaticIpDetector('127.0.0.1')

	- App\CurrentUser
	- App\Api\MeHandler
	- App\Api\ReportHandler

	# every authorization is its own service, so one class can be used with different settings
	anyUser:
		factory: App\RoleTokenAuthorization(%users%)
		autowired: false
	adminOnly:
		factory: App\RoleTokenAuthorization(%users%, requiredRole: admin)
		autowired: false
	partnerKey:
		factory: Tomaj\NetteApi\Authorization\HeaderApiKeyAuthentication('X-API-Key', Tomaj\NetteApi\Misc\StaticTokenRepository(%apiKeys%))
		autowired: false
	userOrPartner:
		factory: App\AnyOfAuthorization(@anyUser, @partnerKey)
		autowired: false

	apiDecider:
		factory: Tomaj\NetteApi\ApiDecider
		setup:
			# any valid bearer token
			- addApi(Tomaj\NetteApi\EndpointIdentifier('GET', 1, 'me'), App\Api\MeHandler, @anyUser)
			# bearer token of a user with the admin role
			- addApi(Tomaj\NetteApi\EndpointIdentifier('GET', 1, 'report'), App\Api\ReportHandler, @adminOnly)
			# a bearer token or the X-API-Key header
			- addApi(Tomaj\NetteApi\EndpointIdentifier('GET', 1, 'me', 'any'), App\Api\MeHandler, @userOrPartner)

The authorization ​

authorized() runs before the handler. It returns false with a message for the 401, or stores the user and returns true.

php
<?php

declare(strict_types=1);

namespace App;

use Nette\Http\IRequest;
use Tomaj\NetteApi\Authorization\ApiAuthorizationInterface;

/**
 * Bearer token that maps to a user with roles. The endpoint passes when the user has the required role.
 */
final class RoleTokenAuthorization implements ApiAuthorizationInterface
{
    private ?string $errorMessage = null;

    /**
     * @param array<string, array{name: string, roles: string[]}> $tokens token => user
     */
    public function __construct(
        private array $tokens,
        private IRequest $request,
        private CurrentUser $currentUser,
        private ?string $requiredRole = null,
    ) {
    }

    public function authorized(): bool
    {
        $header = (string) $this->request->getHeader('Authorization');
        if (!preg_match('/^Bearer\s+(\S+)$/i', $header, $match)) {
            $this->errorMessage = 'Missing bearer token';
            return false;
        }

        $user = null;
        // hash_equals() for every token, so the response time does not tell which one is close
        foreach ($this->tokens as $token => $data) {
            if (hash_equals((string) $token, $match[1])) {
                $user = new ApiUser($data['name'], $data['roles']);
            }
        }

        if ($user === null) {
            $this->errorMessage = 'Unknown token';
            return false;
        }

        if ($this->requiredRole !== null && !$user->hasRole($this->requiredRole)) {
            $this->errorMessage = "Role {$this->requiredRole} is required";
            return false;
        }

        $this->currentUser->set($user);
        return true;
    }

    public function getErrorMessage(): ?string
    {
        return $this->errorMessage;
    }
}

Any of several ​

php
<?php

declare(strict_types=1);

namespace App;

use Tomaj\NetteApi\Authorization\ApiAuthorizationInterface;

/**
 * Passes when any of the given authorizations passes, for example a bearer token or an API key.
 * When all fail, the error message lists why each one failed.
 */
final class AnyOfAuthorization implements ApiAuthorizationInterface
{
    /** @var ApiAuthorizationInterface[] */
    private array $authorizations;

    private ?string $errorMessage = null;

    public function __construct(ApiAuthorizationInterface ...$authorizations)
    {
        $this->authorizations = $authorizations;
    }

    public function authorized(): bool
    {
        $messages = [];
        foreach ($this->authorizations as $authorization) {
            if ($authorization->authorized()) {
                return true;
            }
            $messages[] = $authorization->getErrorMessage();
        }

        $this->errorMessage = implode('; ', array_filter($messages));
        return false;
    }

    public function getErrorMessage(): ?string
    {
        return $this->errorMessage;
    }
}

Passing the user to the handler ​

CurrentUser is a shared service: the authorization writes it, the handler reads it.

php
<?php

declare(strict_types=1);

namespace App;

/**
 * One shared service: the authorization fills it, the handler reads it.
 * ApiPresenter calls authorized() before handle(), so the user is always set when the handler runs.
 */
final class CurrentUser
{
    private ?ApiUser $user = null;

    public function set(ApiUser $user): void
    {
        $this->user = $user;
    }

    public function get(): ?ApiUser
    {
        return $this->user;
    }
}
php
<?php

declare(strict_types=1);

namespace App\Api;

use App\CurrentUser;
use Tomaj\NetteApi\Handlers\BaseHandler;
use Tomaj\NetteApi\Response\JsonApiResponse;
use Tomaj\NetteApi\Response\ResponseInterface;

final class MeHandler extends BaseHandler
{
    public function __construct(private CurrentUser $currentUser)
    {
        parent::__construct();
    }

    public function summary(): string
    {
        return 'Who is calling';
    }

    public function handle(array $params): ResponseInterface
    {
        // set by RoleTokenAuthorization; null when the endpoint uses an API key only
        $user = $this->currentUser->get();
        return new JsonApiResponse(200, [
            'name' => $user?->name ?? 'api-key client',
            'roles' => $user?->roles ?? [],
        ]);
    }
}

The test ​

bash
#!/usr/bin/env bash
# End-to-end check: roles from a bearer token, and one endpoint that accepts a bearer token or an API key.
set -euo pipefail
cd "$(dirname "$0")"
[[ -f vendor/autoload.php ]] || { echo "fail: vendor/ is missing, run composer install first" >&2; exit 1; }
source ../ci/lib.sh
serve 8090

request "$BASE/api/v1/me"
expect_status 401 "no token"
expect_body '{"status":"error","message":"Missing bearer token"}' "no token"
ok "no token answers 401 with the message of the authorization"

request -H 'Authorization: Bearer wrong' "$BASE/api/v1/me"
expect_status 401 "unknown token"
expect_body '"Unknown token"' "unknown token"
ok "an unknown token answers 401"

request -H 'Authorization: Bearer bob-token' "$BASE/api/v1/me"
expect_status 200 "bob"
expect_body '{"name":"Bob","roles":["staff"]}' "bob"
ok "the handler reads the user that the authorization found"

request -H 'Authorization: Bearer bob-token' "$BASE/api/v1/report"
expect_status 401 "bob is not admin"
expect_body '"Role admin is required"' "bob is not admin"
ok "a user without the admin role cannot read the report"

request -H 'Authorization: Bearer alice-token' "$BASE/api/v1/report"
expect_status 200 "alice is admin"
expect_body '"orders":42' "alice is admin"
ok "an admin reads the report"

request -H 'X-API-Key: partner-key' "$BASE/api/v1/me/any"
expect_status 200 "api key"
expect_body '"name":"api-key client"' "api key"
ok "the any-of endpoint accepts an API key"

request -H 'Authorization: Bearer alice-token' "$BASE/api/v1/me/any"
expect_body '"name":"Alice"' "bearer on any-of"
ok "the any-of endpoint accepts a bearer token"

request "$BASE/api/v1/me/any"
expect_status 401 "neither"
expect_body 'Missing bearer token; ' "neither"
ok "with neither, the 401 says why both failed"

FAQ ​

When does authorized() run? ​

After the rate limit and after the params are read, before param errors and before the handler. See ApiPresenter pipeline.

Why does the handler not check the role? ​

The endpoint registration decides who may call it, so the same handler can be public in one place and admins only in another. See Layers.

Why hash_equals() in the loop? ​

It takes the same time however much of the token is right, so the response time does not help guessing it. See Authorization.

What does the 401 body contain? ​

{"status":"error","message":...} with the text from getErrorMessage(). AnyOfAuthorization joins the messages of all failed checks.

How do I show the token in OpenAPI and the console? ​

OpenApiHandler recognises only the built-in classes (instanceof). Extend one of them, for example BearerTokenAuthorization, or pass your own securitySchemes in the handler's initData; a custom class gets no per-operation security either way. See OpenAPI.

Released under the MIT License.