Security & Authentication

Melodic provides a complete authentication and authorization system supporting three provider types — OIDC, OAuth2, and local username/password — and three middleware types for protecting routes. Everything is JWT-based: external providers issue or produce tokens, and the framework validates them on every request.

SecurityServiceProvider

All security services are wired through a single service provider. Register it in your application bootstrap:

use Melodic\Security\SecurityServiceProvider;

$app->register(new SecurityServiceProvider());

This automatically registers the following in the DI container:

  • AuthConfig — parsed from your auth configuration section
  • SessionManager — manages session state for OAuth flows and redirect tracking
  • AuthProviderRegistry — holds all configured auth providers (OIDC, OAuth2, Local)
  • JwtValidator — validates JWT tokens from any source (local or external)
  • AuthLoginRendererInterface — renders the default login page
  • AuthCallbackMiddleware — handles login, callback, and logout routes
  • ApiAuthenticationMiddleware — Bearer token authentication for API routes
  • WebAuthenticationMiddleware — cookie-based authentication for web pages
  • RefreshTokenConfig, RefreshTokenService, RefreshTokenMiddleware — secure refresh token rotation with family-based reuse detection (see Refresh Tokens)

Note: The provider reads all configuration from the auth key in your JSON config file. Make sure loadEnvironmentConfig() (or loadConfig()) is called before register().

Authentication Middleware

Melodic ships with three authentication middleware classes, each designed for a different use case. All three set a UserContext on the request so controllers can access the authenticated user.

ApiAuthenticationMiddleware

Use this for API routes that return JSON responses.

  • Reads the JWT from the Authorization: Bearer <token> header
  • Valid token → sets UserContext on the request, continues to handler
  • Invalid or missing token → returns a 401 JSON response immediately
  • If API auth is disabled (auth.api.enabled = false), sets an anonymous UserContext and continues
use Melodic\Security\ApiAuthenticationMiddleware;

$router->group('/api', function (Router $router) {
    $router->apiResource('/users', UserApiController::class);
}, middleware: [ApiAuthenticationMiddleware::class]);

WebAuthenticationMiddleware

Use this for protected web pages that require a logged-in user.

  • Reads the JWT from the auth cookie (default name: melodic_auth)
  • Valid token → sets UserContext on the request, continues to handler
  • Invalid or missing token → saves the current URL in the session for post-login redirect, then redirects to the login page
  • If web auth is disabled (auth.web.enabled = false), sets an anonymous UserContext and continues
use Melodic\Security\WebAuthenticationMiddleware;

$router->group('/admin', function (Router $router) {
    $router->get('/dashboard', AdminController::class, 'index');
}, middleware: [WebAuthenticationMiddleware::class]);

OptionalWebAuthMiddleware

Use this for public pages that benefit from knowing the user but do not require authentication.

  • Reads the JWT from the auth cookie
  • Valid token → sets UserContext with the authenticated user
  • Invalid or missing token → sets an anonymous UserContext (no redirect, no error)
use Melodic\Security\OptionalWebAuthMiddleware;

$router->group('', function (Router $router) {
    $router->get('/', HomeController::class, 'index');
    $router->get('/about', HomeController::class, 'about');
}, middleware: [OptionalWebAuthMiddleware::class]);

Tip: Use OptionalWebAuthMiddleware on public pages like your home page so you can display a personalized greeting or a "Sign In" link depending on whether the user is logged in.

Middleware Comparison

Middleware Token Source Missing/Invalid Token Use Case
ApiAuthenticationMiddleware Authorization header 401 JSON response API endpoints
WebAuthenticationMiddleware Cookie Redirect to login page Protected web pages
OptionalWebAuthMiddleware Cookie Anonymous context (no redirect) Public pages with optional personalization

Auth Providers

Melodic supports three provider types, each configured in the auth.providers section of your config file. Multiple providers can be registered simultaneously — the login page will display buttons for each external provider and a username/password form for the local provider.

OIDC (OpenID Connect)

The OIDC provider is the most automated option. It uses OIDC Discovery to automatically locate authorization, token, and JWKS endpoints from a single discovery URL.

Configuration properties:

PropertyDescription
type"oidc"
labelButton label on the login page (e.g., "Sign in with Google")
discoveryUrlOIDC discovery document URL (the .well-known/openid-configuration endpoint)
clientIdOAuth client ID from your identity provider
clientSecretOAuth client secret (optional for public clients using PKCE)
redirectUriCallback URL registered with the provider (must match /auth/callback/{name})
scopesSpace-separated scopes (defaults to "openid profile email")

How it works:

  1. On login, the framework fetches the discovery document to find the authorization endpoint
  2. Generates a PKCE code verifier and challenge for security
  3. Redirects the user to the provider's authorization endpoint
  4. On callback, exchanges the authorization code for tokens (using the code verifier)
  5. Validates the ID token using the provider's JWKS (JSON Web Key Set)
  6. Sets the validated claims as the user's identity

Note: OIDC discovery results are cached to disk to avoid repeated HTTP requests. The cache directory is sys_get_temp_dir() . '/melodic_oidc_cache'.

Compatible providers: Google, Azure AD, Auth0, Okta, Keycloak, and any provider that supports OIDC Discovery.

OAuth2

The OAuth2 provider supports generic OAuth2 flows where OIDC Discovery is not available. You specify the endpoints manually, and the framework fetches user info from a separate endpoint after obtaining an access token.

Configuration properties:

PropertyDescription
type"oauth2"
labelButton label on the login page
authorizeUrlProvider's authorization endpoint
tokenUrlProvider's token exchange endpoint
userInfoUrlEndpoint that returns the user's profile (called with the access token)
clientIdOAuth client ID
clientSecretOAuth client secret
redirectUriCallback URL registered with the provider
scopesSpace-separated scopes
claimMapMaps provider-specific field names to standard claims

How it works:

  1. On login, redirects to the provider's authorization URL with state parameter
  2. On callback, exchanges the authorization code for an access token
  3. Fetches user info from the userInfoUrl using the access token
  4. Uses ClaimMapper to map provider-specific fields (e.g., GitHub's id, login) to standard claims (sub, username, email, entitlements)
  5. Issues a local JWT containing the mapped claims

The claimMap object tells the mapper which fields in the provider's response correspond to the standard claim names:

"claimMap": {
    "sub": "id",
    "username": "login",
    "email": "email"
}

If a key is not specified in the claim map, the mapper looks for the standard name (e.g., sub, username, email, entitlements) in the raw response.

Compatible providers: GitHub, GitLab, Bitbucket, and any provider with a standard OAuth2 authorization code flow.

Local (Username/Password)

The local provider handles traditional username and password authentication. It requires you to implement the LocalAuthenticatorInterface to validate credentials against your own data store.

Configuration properties:

PropertyDescription
type"local"
labelSubmit button label (e.g., "Sign In")

Implementing the authenticator:

<?php

use Melodic\Security\LocalAuthenticatorInterface;
use Melodic\Security\SecurityException;

class MyAuthenticator implements LocalAuthenticatorInterface
{
    public function __construct(
        private readonly UserService $userService,
    ) {}

    public function authenticate(string $username, string $password): array
    {
        $user = $this->userService->findByEmail($username);

        if ($user === null || !password_verify($password, $user->passwordHash)) {
            throw new SecurityException('Invalid email or password.');
        }

        return [
            'sub' => (string) $user->id,
            'username' => $user->name,
            'email' => $user->email,
            'entitlements' => $user->roles,
        ];
    }
}

Then bind the interface in your service registration:

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

How it works:

  1. The login page renders a username/password form that POSTs to /auth/callback/{provider}
  2. The LocalAuthProvider extracts the credentials from the POST body
  3. Calls your LocalAuthenticatorInterface implementation to validate credentials
  4. On success, issues a local JWT containing the returned claims
  5. On failure, redirects back to the login page with an error message

Important: The local provider requires a local signing configuration in the auth section. Without it, the provider cannot issue JWT tokens and will throw a SecurityException at startup.

JWT Configuration

The auth.local section in your config file controls how locally-issued JWTs are signed and validated. Both the local auth provider and the OAuth2 provider use this configuration to issue tokens.

PropertyDefaultDescription
signingKey(required)Secret key for HS256 signing. Minimum 32 characters recommended for security.
issuer"melodic-app"The iss claim in issued tokens. Also used by JwtValidator to identify locally-issued tokens.
audience"melodic-app"The aud claim in issued tokens. Validated on incoming tokens.
tokenLifetime3600Seconds until the token expires (sets the exp claim).
algorithm"HS256"The signing algorithm. Must be supported by the firebase/php-jwt library.
{
    "auth": {
        "local": {
            "signingKey": "your-secret-key-at-least-32-characters-long",
            "issuer": "my-app",
            "audience": "my-app",
            "tokenLifetime": 7200,
            "algorithm": "HS256"
        }
    }
}

Important: Use a strong, random signing key in production. Never commit your production signing key to source control. Consider loading it from an environment variable or a secrets manager.

JwtValidator

The JwtValidator class is responsible for validating JWT tokens regardless of their origin. It uses a two-step strategy to determine how to validate each token:

  1. Peek at the issuer — decodes the token payload (without verification) to read the iss claim
  2. Route to the correct validator:
    • If the issuer matches the local auth.local.issuer → validates using the local signing key and algorithm, then checks the audience claim
    • Otherwise → tries each registered OIDC provider's JWKS (JSON Web Key Set) to find one that can validate the token

On success, the validator returns the decoded claims array. On failure, it throws a SecurityException.

// The validator is used internally by the authentication middleware.
// You can also resolve it from the container if needed:
$validator = $container->get(JwtValidator::class);

try {
    $claims = $validator->validate($token);
    // $claims is an associative array of JWT claims
} catch (SecurityException $e) {
    // Token is invalid
}

UserContext

After authentication, the middleware places a UserContext on the request. Controllers access it through the getUserContext() method available on both ApiController and MvcController.

$userContext = $this->getUserContext();

The UserContext provides the following methods:

MethodReturn TypeDescription
isAuthenticated()boolReturns true if a valid user is present (not anonymous).
getUser()?UserReturns the User object, or null if anonymous.
getUsername()?stringShortcut for getUser()->username.
hasEntitlement(string $entitlement)boolChecks if the user has a specific entitlement.
hasAnyEntitlement(string ...$entitlements)boolChecks if the user has at least one of the given entitlements.
getClaim(string $key)mixedReturns a raw JWT claim by key, or null if not present.
getClaims()arrayReturns all JWT claims as an associative array.

The User object has four readonly properties:

  • id (string) — the subject (sub) claim
  • username (string) — display name
  • email (string) — email address
  • entitlements (array) — list of roles or permissions

Usage in Controllers

<?php

class ProfileController extends ApiController
{
    public function show(): Response
    {
        $userContext = $this->getUserContext();

        if (!$userContext->isAuthenticated()) {
            return $this->json(['error' => 'Not logged in'], 401);
        }

        $user = $userContext->getUser();

        return $this->json([
            'id' => $user->id,
            'username' => $user->username,
            'email' => $user->email,
            'isAdmin' => $userContext->hasEntitlement('admin'),
            'provider' => $userContext->getClaim('provider'),
        ]);
    }
}

Usage in Templates

<!-- Pass userContext to viewBag in controller -->
<!-- $this->viewBag->userContext = $this->getUserContext(); -->

<?php $uc = $viewBag->userContext; ?>
<?php if ($uc && $uc->isAuthenticated()): ?>
    <p>Welcome, <?= htmlspecialchars($uc->getUsername()) ?>!</p>
<?php else: ?>
    <p><a href="/auth/login">Sign in</a></p>
<?php endif; ?>

AuthorizationMiddleware

After authentication establishes who the user is, the AuthorizationMiddleware checks what they are allowed to do. It examines the UserContext set by the authentication middleware.

use Melodic\Security\AuthorizationMiddleware;

new AuthorizationMiddleware(
    requiredEntitlements: ['admin'],
    requireAuthentication: true,
)
ParameterDefaultDescription
requiredEntitlements[]Array of entitlement strings. The user must have at least one of them (OR logic).
requireAuthenticationtrueIf true, unauthenticated requests receive a 401 response.

Behavior:

  • Unauthenticated user + requireAuthentication: true401 JSON response
  • Authenticated user without required entitlements → 403 JSON response
  • Authenticated user with at least one required entitlement → continues to handler

Applying to Route Groups

$router->group('/api', function (Router $router) {
    // All routes require authentication (from ApiAuthenticationMiddleware)
    // Admin routes additionally require the 'admin' entitlement
    $router->group('/admin', function (Router $router) {
        $router->get('/stats', AdminController::class, 'stats');
        $router->delete('/users/{id}', AdminController::class, 'removeUser');
    }, middleware: [new AuthorizationMiddleware(
        requiredEntitlements: ['admin'],
    )]);

    $router->apiResource('/users', UserApiController::class);
}, middleware: [ApiAuthenticationMiddleware::class]);

Note: Always place AuthorizationMiddleware after an authentication middleware. Authorization checks the UserContext that authentication sets on the request. Without authentication running first, there will be no UserContext to check.

Auth Routes

The AuthCallbackMiddleware intercepts specific paths and handles the entire login/callback/logout flow. These are the routes it responds to:

MethodPathAction
GET/auth/loginRenders the login page with provider buttons and/or local form
GET/auth/login/{provider}Initiates OAuth redirect to the named provider
GET/auth/callback/{provider}Handles OAuth callback (authorization code exchange)
POST/auth/callback/{provider}Handles local form submission (username/password)
GET/auth/logoutClears the auth cookie and redirects to /

On successful authentication, the middleware sets the JWT as an HTTP-only cookie and redirects the user to their original destination (saved in the session by WebAuthenticationMiddleware) or to the postLoginRedirect path (default /).

Setting Up Auth Routes

Register all auth endpoints in a route group with AuthCallbackMiddleware. The route actions are never reached because the middleware handles the request directly — the controller references are placeholders:

use Melodic\Security\AuthCallbackMiddleware;

$router->group('/auth', function (Router $router) {
    $router->get('/login', HomeController::class, 'index');
    $router->get('/login/{provider}', HomeController::class, 'index');
    $router->get('/callback/{provider}', HomeController::class, 'index');
    $router->post('/callback/{provider}', HomeController::class, 'index');
    $router->get('/logout', HomeController::class, 'index');
}, middleware: [AuthCallbackMiddleware::class]);

Tip: The controller class referenced in the auth routes does not matter because AuthCallbackMiddleware intercepts the request before it reaches the controller. However, you still need valid route registrations so the router matches the paths.

Login Page Customization

The default login page renderer produces a clean, centered card with provider buttons and an optional local login form. You can customize it in two ways.

Config-Based Styling

Add a loginPage object to your auth configuration to change colors, branding, and styles without writing any code:

{
    "auth": {
        "loginPage": {
            "title": "Welcome Back",
            "primaryColor": "#6c63ff",
            "primaryHoverColor": "#5a52e0",
            "backgroundColor": "#0f1117",
            "cardBackground": "#1c2030",
            "textColor": "#e4e4e7",
            "subtextColor": "#8b8b9e",
            "logoUrl": "/images/logo.png",
            "logoAlt": "My App Logo",
            "faviconUrl": "/favicon.ico",
            "customCss": ".login-card { border: 1px solid #333; }"
        }
    }
}
PropertyDefaultDescription
title"Sign In"Page heading and <title>
primaryColor"#4a90d9"Submit button and focus ring color
primaryHoverColor"#357abd"Submit button hover color
backgroundColor"#f5f5f5"Page background color
cardBackground"#ffffff"Login card background color
textColor"#333333"Heading text color
subtextColor"#555555"Label text color
logoUrlnullURL to a logo image displayed above the title
logoAltnullAlt text for the logo image
faviconUrlnullURL for a custom favicon
customCssnullRaw CSS injected into the page for additional styling

Custom Renderer

For complete control over the login page, implement AuthLoginRendererInterface and bind it in the DI container:

<?php

use Melodic\Security\AuthLoginRendererInterface;

class MyLoginRenderer implements AuthLoginRendererInterface
{
    public function render(?string $error = null): string
    {
        // Return the full HTML for your custom login page
        $errorHtml = $error
            ? '<div class="error">' . htmlspecialchars($error) . '</div>'
            : '';

        return "<!DOCTYPE html><html>...{$errorHtml}...</html>";
    }
}
$app->services(function (Container $container) {
    $container->singleton(
        AuthLoginRendererInterface::class,
        MyLoginRenderer::class,
    );
});

Note: This works because $app->services() runs after $app->register(). The SecurityServiceProvider registers the default renderer, and your binding replaces it. The container uses the last registered binding for each interface.

Complete Provider Configuration

Here is a full configuration example with all three provider types configured together.

{
    "auth": {
        "api": { "enabled": true },
        "web": { "enabled": true },
        "loginPath": "/auth/login",
        "callbackPath": "/auth/callback",
        "postLoginRedirect": "/",
        "cookieName": "melodic_auth",
        "cookieLifetime": 3600,
        "local": {
            "signingKey": "your-secret-key-at-least-32-characters-long",
            "issuer": "my-app",
            "audience": "my-app",
            "tokenLifetime": 3600,
            "algorithm": "HS256"
        },
        "providers": {
            "google": {
                "type": "oidc",
                "label": "Sign in with Google",
                "discoveryUrl": "https://accounts.google.com/.well-known/openid-configuration",
                "clientId": "YOUR_GOOGLE_CLIENT_ID",
                "clientSecret": "YOUR_GOOGLE_CLIENT_SECRET",
                "redirectUri": "https://myapp.com/auth/callback/google",
                "scopes": "openid profile email"
            },
            "github": {
                "type": "oauth2",
                "label": "Sign in with GitHub",
                "authorizeUrl": "https://github.com/login/oauth/authorize",
                "tokenUrl": "https://github.com/login/oauth/access_token",
                "userInfoUrl": "https://api.github.com/user",
                "clientId": "YOUR_GITHUB_CLIENT_ID",
                "clientSecret": "YOUR_GITHUB_CLIENT_SECRET",
                "redirectUri": "https://myapp.com/auth/callback/github",
                "scopes": "read:user user:email",
                "claimMap": {
                    "sub": "id",
                    "username": "login",
                    "email": "email"
                }
            },
            "local": {
                "type": "local",
                "label": "Sign In"
            }
        },
        "loginPage": {
            "title": "Welcome to My App",
            "primaryColor": "#6c63ff",
            "logoUrl": "/images/logo.png"
        }
    }
}

Important: The local signing config (auth.local) is required when using OAuth2 or Local providers, because both issue locally-signed JWTs. OIDC-only setups can omit it if you only validate tokens using the provider's JWKS. However, including it is recommended so the JwtValidator can route tokens correctly.

Complete Bootstrap Example

Here is a full application bootstrap with security fully configured:

<?php

declare(strict_types=1);

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

use App\Auth\MyAuthenticator;
use Melodic\Core\Application;
use Melodic\Http\Middleware\CorsMiddleware;
use Melodic\Http\Middleware\JsonBodyParserMiddleware;
use Melodic\Security\ApiAuthenticationMiddleware;
use Melodic\Security\AuthCallbackMiddleware;
use Melodic\Security\LocalAuthenticatorInterface;
use Melodic\Security\OptionalWebAuthMiddleware;
use Melodic\Security\SecurityServiceProvider;
use Melodic\Security\WebAuthenticationMiddleware;

$app = new Application(dirname(__DIR__));
$app->loadEnvironmentConfig();

// Register the security provider (reads auth config automatically)
$app->register(new SecurityServiceProvider());

// Register application services
$app->services(function ($container) {
    // Required for the local auth provider
    $container->bind(
        LocalAuthenticatorInterface::class,
        MyAuthenticator::class,
    );
});

// Add global middleware
$app->addMiddleware(new CorsMiddleware($app->config('cors') ?? []));
$app->addMiddleware(new JsonBodyParserMiddleware());

// Define routes
$app->routes(function ($router) {
    // Public pages with optional auth
    $router->group('', function ($router) {
        $router->get('/', HomeController::class, 'index');
    }, middleware: [OptionalWebAuthMiddleware::class]);

    // Auth endpoints
    $router->group('/auth', function ($router) {
        $router->get('/login', HomeController::class, 'index');
        $router->get('/login/{provider}', HomeController::class, 'index');
        $router->get('/callback/{provider}', HomeController::class, 'index');
        $router->post('/callback/{provider}', HomeController::class, 'index');
        $router->get('/logout', HomeController::class, 'index');
    }, middleware: [AuthCallbackMiddleware::class]);

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

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

$app->run();