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
authconfiguration 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
UserContexton the request, continues to handler - Invalid or missing token → returns a
401 JSONresponse immediately - If API auth is disabled (
auth.api.enabled = false), sets an anonymousUserContextand 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
UserContexton 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 anonymousUserContextand 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
UserContextwith 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:
| Property | Description |
|---|---|
type | "oidc" |
label | Button label on the login page (e.g., "Sign in with Google") |
discoveryUrl | OIDC discovery document URL (the .well-known/openid-configuration endpoint) |
clientId | OAuth client ID from your identity provider |
clientSecret | OAuth client secret (optional for public clients using PKCE) |
redirectUri | Callback URL registered with the provider (must match /auth/callback/{name}) |
scopes | Space-separated scopes (defaults to "openid profile email") |
How it works:
- On login, the framework fetches the discovery document to find the authorization endpoint
- Generates a PKCE code verifier and challenge for security
- Redirects the user to the provider's authorization endpoint
- On callback, exchanges the authorization code for tokens (using the code verifier)
- Validates the ID token using the provider's JWKS (JSON Web Key Set)
- 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:
| Property | Description |
|---|---|
type | "oauth2" |
label | Button label on the login page |
authorizeUrl | Provider's authorization endpoint |
tokenUrl | Provider's token exchange endpoint |
userInfoUrl | Endpoint that returns the user's profile (called with the access token) |
clientId | OAuth client ID |
clientSecret | OAuth client secret |
redirectUri | Callback URL registered with the provider |
scopes | Space-separated scopes |
claimMap | Maps provider-specific field names to standard claims |
How it works:
- On login, redirects to the provider's authorization URL with state parameter
- On callback, exchanges the authorization code for an access token
- Fetches user info from the
userInfoUrlusing the access token - Uses
ClaimMapperto map provider-specific fields (e.g., GitHub'sid,login) to standard claims (sub,username,email,entitlements) - 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:
| Property | Description |
|---|---|
type | "local" |
label | Submit 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:
- The login page renders a username/password form that POSTs to
/auth/callback/{provider} - The
LocalAuthProviderextracts the credentials from the POST body - Calls your
LocalAuthenticatorInterfaceimplementation to validate credentials - On success, issues a local JWT containing the returned claims
- 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.
| Property | Default | Description |
|---|---|---|
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. |
tokenLifetime | 3600 | Seconds 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:
- Peek at the issuer — decodes the token payload (without verification) to read the
issclaim - 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
- If the issuer matches the local
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:
| Method | Return Type | Description |
|---|---|---|
isAuthenticated() | bool | Returns true if a valid user is present (not anonymous). |
getUser() | ?User | Returns the User object, or null if anonymous. |
getUsername() | ?string | Shortcut for getUser()->username. |
hasEntitlement(string $entitlement) | bool | Checks if the user has a specific entitlement. |
hasAnyEntitlement(string ...$entitlements) | bool | Checks if the user has at least one of the given entitlements. |
getClaim(string $key) | mixed | Returns a raw JWT claim by key, or null if not present. |
getClaims() | array | Returns all JWT claims as an associative array. |
The User object has four readonly properties:
id(string) — the subject (sub) claimusername(string) — display nameemail(string) — email addressentitlements(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,
)
| Parameter | Default | Description |
|---|---|---|
requiredEntitlements | [] | Array of entitlement strings. The user must have at least one of them (OR logic). |
requireAuthentication | true | If true, unauthenticated requests receive a 401 response. |
Behavior:
- Unauthenticated user +
requireAuthentication: true→401 JSONresponse - Authenticated user without required entitlements →
403 JSONresponse - 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:
| Method | Path | Action |
|---|---|---|
| GET | /auth/login | Renders 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/logout | Clears 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; }"
}
}
}
| Property | Default | Description |
|---|---|---|
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 |
logoUrl | null | URL to a logo image displayed above the title |
logoAlt | null | Alt text for the logo image |
faviconUrl | null | URL for a custom favicon |
customCss | null | Raw 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();