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 and JWKS results are cached to disk to avoid repeated HTTP requests. The cache directory defaults to sys_get_temp_dir() . '/melodic_oidc_cache' and is created with 0700 permissions. Override its location with the auth.oidcCacheDir config key.
Signing algorithm (3.0). JWKS keys are parsed with a configurable default algorithm, set per provider via signingAlg (default RS256). This fixes validation for providers — notably Microsoft Entra — that omit the per-key alg parameter in their JWKS. The framework also requires that the token's iss matches the discovery issuer and that an exp claim is present.
Callback hardening (4.0). The OIDC callback is now strict about the tokens it accepts:
- An
id_tokenis required — the previous fallback to theaccess_tokenis gone. If you relied on that fallback, use the OAuth2 provider type instead. - Full
nonceround-trip — a nonce is generated and stored in the session at login, sent on the authorization request, and verified against thenonceclaim in the returnedid_token. A captured or replayed token carries the wrong (or no) nonce and is rejected. - Issuer host must match the discovery host — the discovery document's
issuerhost must equal the host of thediscoveryUrl, so a compromised discovery endpoint cannot pivot validation to a foreign issuer.
Standards-compliant IdPs (Microsoft Entra, Auth0, Okta, Keycloak, Google) are unaffected as long as your scopes include openid (the default).
Compatible providers: Google, Microsoft Entra (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
response_type=code, astateparameter, and a PKCE challenge (S256) - On callback, exchanges the authorization code for an access token (sending the PKCE code verifier)
- 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 HMAC (HS*) signing. Must be at least 32 characters — LocalAuthConfig throws an InvalidArgumentException on an empty or short key (3.0). |
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: Since 3.0, an HMAC signing key shorter than 32 characters is rejected at startup. Generate a strong secret, e.g. openssl rand -base64 48. Never commit your production signing key to source control — load 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. A local token must carry anexpclaim. - 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
Stricter validation (3.0). Tokens are now rejected unless their iss matches the expected issuer (the provider's discovery issuer for OIDC, or auth.local.issuer for local tokens) and an exp claim is present. The audience is validated against the configured value, falling back to the provider's clientId. Verify that your identity provider issues both iss and exp.
On success, the validator returns the decoded claims array. On failure, it throws a SecurityException.
Error-detail masking (4.0). Outside debug mode, the specific reason carried by a SecurityException (expired, bad signature, wrong issuer, etc.) is replaced with a generic Authentication failed. in the response the client sees. The real reason is logged server-side. Enable debug mode locally to see the underlying message while developing.
// 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
Entitlements always require authentication (4.0). A route that declares requiredEntitlements now rejects anonymous requests with 401 even when requireAuthentication is false — an anonymous request can never satisfy an entitlement, so letting it through would silently disable the check. Authenticated users who lack the entitlement still get 403. Guidance: if a route should allow anonymous access, don't declare entitlements on it.
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, CSRF-validated) |
| POST | /auth/logout | Clears the auth cookie and redirects to / — requires a valid csrf_token |
Logout is a CSRF-protected POST (3.0). A previous GET /auth/logout could be triggered cross-site to log users out, so logout is now POST /auth/logout with a CSRF token. A non-POST request returns 405; a POST without a valid csrf_token returns 403. Replace logout links with a small form:
<form method="post" action="/auth/logout">
<input type="hidden" name="csrf_token" value="<?= $this->e($csrfToken) ?>">
<button type="submit">Log out</button>
</form>
Obtain the token from CsrfToken::getToken() (the login renderer is already passed one).
Mismatched CSRF tokens no longer consume the stored token (4.0). A forged or garbage POST that fails CSRF validation leaves the session's stored token intact — the token is consumed only on a successful match. This prevents an attacker from invalidating the token embedded in the user's legitimately rendered form (a CSRF-protection self-DoS): the user's open form keeps working. Use CsrfToken::getToken() when rendering forms so repeated renders reuse the single stored token rather than churning it.
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 /).
Redirect-after-login is now local-only (4.0). The original destination is stored and honored only when it is a local path — a single leading /, not //host or /\host, which browsers normalize to an off-site URL. WebAuthenticationMiddleware refuses to save a non-local return path, and AuthCallbackMiddleware re-checks on the way out and falls back to postLoginRedirect. This closes an open-redirect hole where a crafted return URL (e.g. /\evil.com) could bounce the user off-site after login.
RedirectResponse::local() (4.0). Whenever you build a redirect from a value that could originate outside your code (query string, form field, stored return URL), use RedirectResponse::local($path) instead of new RedirectResponse($path). It throws InvalidArgumentException if $path is not a local path, turning an open redirect into a hard failure. The underlying check is exposed as RedirectResponse::isLocalPath($path) if you need to validate a path yourself.
use Melodic\Http\RedirectResponse;
// Safe: rejects "//evil.com", "https://evil.com", "/\evil.com"
return RedirectResponse::local($returnTo);
Cookies default to Secure (4.0). The auth cookie honors cookieSecure (default true), and any cookie you queue yourself with Response::withCookie() is now Secure + HttpOnly + SameSite=Lax by default. Browsers will not store a Secure cookie delivered over plain HTTP, so for local plain-HTTP development you must opt out explicitly:
$response->withCookie('name', 'value', ['secure' => false]);
Production HTTPS deployments need no change.
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->post('/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,
"cookieSecure": true,
"cookieSameSite": "Lax",
"oidcCacheDir": "storage/cache/oidc",
"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->post('/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();