Adding Authentication

In this tutorial you will add JWT-based authentication to an existing Melodic application. You will protect API routes with bearer tokens, protect web routes with cookie-based sessions, and access user information in controllers and templates.

What you will build: A secured application where API endpoints require a bearer token, web pages redirect unauthenticated users to a login page, and controllers can check user identity and entitlements.

Step 1: Register the SecurityServiceProvider

Melodic ships with a SecurityServiceProvider that registers all authentication-related services in the DI container: AuthConfig, JwtValidator, ApiAuthenticationMiddleware, WebAuthenticationMiddleware, and more.

Register it in your public/index.php before defining routes:

<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

use Melodic\Core\Application;
use Melodic\Http\Middleware\JsonBodyParserMiddleware;
use Melodic\Security\SecurityServiceProvider;

$app = new Application(dirname(__DIR__));
$app->loadConfig('config/config.json');

$app->addMiddleware(new JsonBodyParserMiddleware());

// Register security services
$app->register(new SecurityServiceProvider());

// ... services, routes, and $app->run() follow

The register() method calls the provider's register() to bind services, then later calls boot() when the application runs.

Step 2: Configure Authentication

Add an auth section to your config/config.json. This example configures a local authentication provider with a symmetric signing key:

{
    "app": {
        "name": "My App",
        "debug": true
    },
    "auth": {
        "apiAuthEnabled": true,
        "webAuthEnabled": true,
        "cookieName": "auth_token",
        "loginPath": "/auth/login",
        "local": {
            "signingKey": "your-secret-key-at-least-32-characters-long",
            "algorithm": "HS256",
            "issuer": "my-app",
            "audience": "my-app",
            "tokenLifetime": 3600
        },
        "providers": {
            "local": {
                "type": "local",
                "name": "local",
                "clientId": "my-app"
            }
        }
    }
}

Security: In production, use a strong random signing key and store it outside your configuration file (for example, in an environment variable). The key shown here is for demonstration only.

Configuration options:

KeyPurpose
apiAuthEnabledWhen true, API middleware rejects requests without a valid bearer token
webAuthEnabledWhen true, web middleware redirects unauthenticated users to the login page
cookieNameName of the cookie that stores the JWT for web authentication
loginPathWhere unauthenticated web users are redirected
local.signingKeySymmetric key used to sign and verify JWT tokens
local.tokenLifetimeToken expiry in seconds

Step 3: Implement a LocalAuthenticator

The local auth provider needs a LocalAuthenticatorInterface implementation to validate credentials. This is where you check usernames and passwords against your database.

Create src/Security/AppAuthenticator.php:

<?php

declare(strict_types=1);

namespace App\Security;

use Melodic\Security\AuthResult;
use Melodic\Security\LocalAuthenticatorInterface;

class AppAuthenticator implements LocalAuthenticatorInterface
{
    public function authenticate(string $username, string $password): AuthResult
    {
        // In a real app, look up the user in the database and verify the password hash.
        // This example uses hardcoded credentials for demonstration.
        $users = [
            'admin' => [
                'password' => 'secret',
                'email' => 'admin@example.com',
                'entitlements' => ['admin', 'posts:write'],
            ],
            'reader' => [
                'password' => 'secret',
                'email' => 'reader@example.com',
                'entitlements' => ['posts:read'],
            ],
        ];

        if (!isset($users[$username]) || $users[$username]['password'] !== $password) {
            return AuthResult::failure('Invalid username or password.');
        }

        $user = $users[$username];

        return AuthResult::success([
            'sub' => $username,
            'username' => $username,
            'email' => $user['email'],
            'entitlements' => $user['entitlements'],
        ]);
    }
}

Register the authenticator in your DI container (in public/index.php, before registering the SecurityServiceProvider):

use App\Security\AppAuthenticator;
use Melodic\Security\LocalAuthenticatorInterface;

$app->services(function ($container) {
    $container->bind(LocalAuthenticatorInterface::class, AppAuthenticator::class);
});

// Register security AFTER the authenticator binding is in place
$app->register(new SecurityServiceProvider());

Tip: The SecurityServiceProvider resolves LocalAuthenticatorInterface from the container when building the local auth provider. Register your binding first so it is available.

Step 4: Protect API Routes

Use ApiAuthenticationMiddleware on your API route group. The middleware reads the Authorization: Bearer <token> header, validates the JWT, and attaches a UserContext to the request.

use Melodic\Security\ApiAuthenticationMiddleware;

$app->routes(function ($router) {
    // Public routes (no authentication)
    $router->get('/', HomeController::class, 'index');

    // Protected API routes
    $router->group('/api', function ($router) {
        $router->apiResource('/tasks', TaskApiController::class);
    }, middleware: [ApiAuthenticationMiddleware::class]);
});

When a request hits /api/tasks without a valid token, the middleware short-circuits the pipeline and returns:

HTTP/1.1 401 Unauthorized
Content-Type: application/json

{"error": "Authentication required."}

Step 5: Protect Web Routes

Use WebAuthenticationMiddleware for browser-facing routes. Instead of returning a 401, it redirects unauthenticated users to the configured login page.

use Melodic\Security\WebAuthenticationMiddleware;

$app->routes(function ($router) {
    // Public pages
    $router->get('/', HomeController::class, 'index');

    // Protected pages (redirect to login if not authenticated)
    $router->group('/dashboard', function ($router) {
        $router->get('', DashboardController::class, 'index');
        $router->get('/settings', DashboardController::class, 'settings');
    }, middleware: [WebAuthenticationMiddleware::class]);
});

The web middleware:

  1. Reads the JWT from the cookie specified by auth.cookieName
  2. If the token is valid, creates a UserContext and continues
  3. If the token is missing or invalid, saves the current URL and redirects to auth.loginPath

Step 6: Access UserContext in Controllers

Both ApiController and MvcController provide a getUserContext() method that retrieves the UserContext from the request attributes.

In an API Controller

<?php

declare(strict_types=1);

namespace App\Controllers;

use Melodic\Controller\Controller;
use Melodic\Http\Response;

class TaskApiController extends Controller
{
    public function index(): Response
    {
        $userContext = $this->request->getAttribute('userContext');

        // Access user information
        $username = $userContext->getUsername();
        $userId = $userContext->getUser()->id;

        // Return user-specific data
        return $this->json([
            'user' => $username,
            'tasks' => [], // ... fetch tasks for this user
        ]);
    }
}

In an MVC Controller

<?php

declare(strict_types=1);

namespace App\Controllers;

use Melodic\Controller\MvcController;
use Melodic\Http\Response;
use Melodic\View\ViewEngine;

class DashboardController extends MvcController
{
    public function __construct(ViewEngine $viewEngine)
    {
        parent::__construct($viewEngine);
    }

    public function index(): Response
    {
        $userContext = $this->getUserContext();

        $this->viewBag->title = 'Dashboard';
        $this->viewBag->username = $userContext->getUsername();
        $this->setLayout('layouts/main');

        return $this->view('dashboard/index');
    }
}

The UserContext object provides these methods:

MethodReturns
isAuthenticated()bool — true if a valid user is present
getUser()User|null — the authenticated user object
getUsername()string|null — the user's username
hasEntitlement(string)bool — check a single entitlement
hasAnyEntitlement(string ...)bool — check if user has any of the listed entitlements
getProvider()string|null — which auth provider authenticated the user
getClaim(string, mixed)mixed — get a raw JWT claim value

Step 7: Check Entitlements

Entitlements are permission strings included in the JWT claims. Use them to implement fine-grained access control in your controllers.

public function destroy(int $id): Response
{
    $userContext = $this->request->getAttribute('userContext');

    // Only admins can delete tasks
    if (!$userContext->hasEntitlement('admin')) {
        return $this->forbidden(['error' => 'Admin access required']);
    }

    $this->taskService->delete($id);

    return $this->noContent();
}

You can also check for multiple entitlements:

// User needs at least one of these entitlements
if (!$userContext->hasAnyEntitlement('admin', 'posts:write')) {
    return $this->forbidden(['error' => 'Insufficient permissions']);
}

Entitlements vs. Roles: Melodic uses entitlements (fine-grained permissions) rather than roles. You can model roles as groups of entitlements in your authenticator by assigning the appropriate strings to each user.

Step 8: Show User Info in Templates

Pass the UserContext to your views through ViewBag or template data to display user information and conditionally show content.

In your controller:

public function index(): Response
{
    $userContext = $this->getUserContext();

    $this->viewBag->title = 'Dashboard';
    $this->viewBag->user = $userContext;
    $this->setLayout('layouts/main');

    return $this->view('dashboard/index');
}

In your template (views/dashboard/index.phtml):

<h1>Welcome, <?= htmlspecialchars($viewBag->user->getUsername()) ?></h1>

<?php if ($viewBag->user->hasEntitlement('admin')): ?>
    <div class="admin-panel">
        <h2>Admin Panel</h2>
        <p>You have administrator access.</p>
        <a href="/dashboard/settings">Site Settings</a>
    </div>
<?php endif; ?>

<p>Logged in via: <?= htmlspecialchars($viewBag->user->getProvider() ?? 'unknown') ?></p>
<p>Email: <?= htmlspecialchars($viewBag->user->getUser()->email ?? '') ?></p>

In the layout, you can show a login/logout link based on authentication state:

<nav>
    <a href="/">Home</a>
    <?php if (isset($viewBag->user) && $viewBag->user->isAuthenticated()): ?>
        <a href="/dashboard">Dashboard</a>
        <span>Hi, <?= htmlspecialchars($viewBag->user->getUsername()) ?></span>
    <?php else: ?>
        <a href="/auth/login">Log In</a>
    <?php endif; ?>
</nav>

Complete Bootstrap Example

Here is a full public/index.php that ties everything together:

<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

use App\Controllers\DashboardController;
use App\Controllers\HomeController;
use App\Controllers\TaskApiController;
use App\Security\AppAuthenticator;
use Melodic\Core\Application;
use Melodic\Data\DbContext;
use Melodic\Data\DbContextInterface;
use Melodic\Http\Middleware\JsonBodyParserMiddleware;
use Melodic\Security\ApiAuthenticationMiddleware;
use Melodic\Security\LocalAuthenticatorInterface;
use Melodic\Security\SecurityServiceProvider;
use Melodic\Security\WebAuthenticationMiddleware;
use Melodic\View\ViewEngine;

$app = new Application(dirname(__DIR__));
$app->loadConfig('config/config.json');

$app->addMiddleware(new JsonBodyParserMiddleware());

// Register services
$app->services(function ($container) use ($app) {
    $container->singleton(DbContextInterface::class, function () use ($app) {
        $pdo = new PDO($app->config('database.dsn'));
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

        return new DbContext($pdo);
    });

    $container->singleton(ViewEngine::class, function () use ($app) {
        return new ViewEngine($app->getBasePath() . '/views');
    });

    $container->bind(LocalAuthenticatorInterface::class, AppAuthenticator::class);
});

// Register security provider (after LocalAuthenticatorInterface is bound)
$app->register(new SecurityServiceProvider());

// Routes
$app->routes(function ($router) {
    // Public
    $router->get('/', HomeController::class, 'index');

    // Protected API
    $router->group('/api', function ($router) {
        $router->apiResource('/tasks', TaskApiController::class);
    }, middleware: [ApiAuthenticationMiddleware::class]);

    // Protected web pages
    $router->group('/dashboard', function ($router) {
        $router->get('', DashboardController::class, 'index');
        $router->get('/settings', DashboardController::class, 'settings');
    }, middleware: [WebAuthenticationMiddleware::class]);
});

$app->run();

Testing Authentication with curl

To test the API authentication, you need a valid JWT. If you are using the local provider, the auth callback endpoint issues tokens after successful login. You can also generate a token manually for testing:

# Request without a token (expect 401)
curl -v http://localhost:8080/api/tasks

# Request with a valid bearer token
curl http://localhost:8080/api/tasks \
  -H "Authorization: Bearer <your-jwt-token>"

Next steps: Explore the Security documentation for OIDC and OAuth2 provider configuration, or learn to build custom middleware for additional request processing.