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.
| Request | Response |
|---|---|
GET /api/v1/me without a token | 401 Missing bearer token |
GET /api/v1/me with Bearer bob-token | 200 {"name":"Bob","roles":["staff"]} |
GET /api/v1/report with Bearer bob-token | 401 Role admin is required |
GET /api/v1/report with Bearer alice-token | 200 |
GET /api/v1/any/me with X-API-Key: partner-key | 200, the API key client |
The services
Each authorization is a separate service with autowired: false, so one class serves several endpoints with different settings.
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
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
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
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
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
#!/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.