Auth, API keys, webhooks, DI y multi-tenancy¶
Authentication¶
Login¶
POST /auth/login
Content-Type: application/json
{"usuario": "email@example.com", "clave": "password"}
Returns access_token (JWT) and refresh_token (opaque, stored in DB).
The JWT payload contains sub (user ID), tenant_id, and expiry. Default TTL: 86400 seconds (configurable via JWT_TTL).
Token refresh¶
Issues a new access_token + refresh_token pair. The old refresh token is deleted immediately (rotation — each token is single-use).
Logout¶
POST /auth/logout
Authorization: Bearer <access_token>
Content-Type: application/json
{"refresh_token": "a8f3c1d9..."} ← optional, also invalidates refresh token
Impersonation (admin only)¶
POST /auth/impersonate
Authorization: Bearer <admin_access_token>
Content-Type: application/json
{"target_id": 42}
- Requires
AuthMiddleware + AdminMiddleware + TenantMiddleware - Admin can only impersonate users within their own tenant
- Returns a JWT signed as the target user
Rate limiting¶
Login attempts are tracked per username using APCu. After 5 failed attempts the account is locked for 5 minutes (RateLimitException → 429). If APCu is not installed, rate limiting is silently skipped.
Authenticated request¶
AuthMiddleware resolves the request through guards (JWT first, then API
key) and produces a unified Principal. For backward compatibility it still
calls $request->setUser($payload), so $request->user(),
TenantMiddleware and PermissionMiddleware work unchanged. Use
$request->principal() to read the auth type, tenant and scopes.
API keys (third-party auth)¶
For server-to-server access by external developers, the same protected routes accept an API key instead of a user JWT — no code change on the route:
Keys are issued with App\Support\Auth\ApiKeyManager::issue($tenantId, $name, $scopes),
which returns the token once (only prefix + a SHA-256 hash are stored).
Tenants manage their own keys through the built-in ApiKeys module (CRUD):
POST /api-keys { "name": "...", "scopes": ["clientes.read"] } → 201, token shown once
GET /api-keys → list (never exposes hash)
GET /api-keys/{id} → one key's metadata
DELETE /api-keys/{id} → revoke
These routes require AuthMiddleware + TenantMiddleware + ScopeMiddleware:apikeys.manage,
so app users (scope *) manage keys transparently, while an API key can only
administer others if explicitly granted apikeys.manage (prevents privilege
escalation). Every operation is scoped to the caller's tenant.
The key carries its tenant and a list of scopes; guard against them per route with the parametrized middleware:
$router->get('/clientes', [ClienteController::class, 'index'],
[AuthMiddleware::class, TenantMiddleware::class, 'App\Http\Middleware\ScopeMiddleware:clientes.read']);
Scopes (what a credential may touch) are orthogonal to RBAC permissions (what a
user role may do) and to tenant entitlements (what a tenant has) — see
docs/adr/0001-saas-identity-entitlements-billing.md.
Webhook signatures¶
App\Support\Webhook\WebhookVerifier (bound to WebhookVerifierInterface) hardens
inbound/outbound webhooks with a dependency-free scheme:
verify($request, $secret, $tolerance = 300) returns true only if the HMAC matches
(constant-time), the timestamp is within the window, and the signature hasn't been
seen before (anti-replay via CacheInterface, TTL = window). sign($payload, $secret)
produces the header for outbound webhooks. Inject the interface in any controller that
receives provider callbacks (e.g. payment gateways) and verify against that integration's
secret before acting. Reading the raw body relies on Request::rawBody().
El anti-replay exige un store operativo. El nonce vive en CacheInterface, cuyo binding
por defecto es ApcuCache. Si el cache no es operativo (available() === false, p. ej. APCu
deshabilitado), verify() falla cerrado — rechaza la firma en vez de aceptarla sin
protección — y se loguea un aviso al bootear. Además, APCu es por proceso: en un deploy
multi-instancia, vinculá CacheInterface a un store compartido (Redis/DB) implementando la
interfaz, o un reenvío podría no detectarse al caer en otra instancia. El esquema HMAC no
cambia. Ver docs/adr/0001-saas-identity-entitlements-billing.md §1.3.
DI Container¶
PSR-11 compliant with reflection-based autowiring.
// Register a factory
$app->bind(MyService::class, fn ($c) => new MyService($c->get(PDO::class)));
// Register a singleton (resolved once, reused)
$app->singleton(MyService::class, fn ($c) => new MyService($c->get(PDO::class)));
// Register a pre-built instance
$app->instance(\PDO::class, $existingPdo);
// Resolve
$service = $app->get(MyService::class);
// Autowire without registration (uses reflection)
$service = $app->make(MyService::class);
// Autowire and inject extra scalar params into builtin constructor parameters
$middleware = $app->makeWith(PermissionMiddleware::class, 'facturas.delete');
Autowiring resolves constructor parameters by type name. If no binding exists for a type, it recursively resolves the class. Scalar parameters without defaults throw ContainerException. makeWith injects additional scalars positionally into builtin-typed parameters — used internally by the Router for parameterized middlewares.
Multi-tenancy¶
The framework ships with row-level multi-tenancy. It is opt-in — if you don't include TenantMiddleware on a route, tenantId is never set and no tenant scoping happens.
How it works¶
- The
usuariostable has atenant_id CHAR(36)column (FK →tenants.id) - On login,
tenant_idis embedded in the JWT payload TenantMiddlewarereadstenant_idfrom the decoded JWT and calls$request->setTenantId()- Controllers read
$request->tenantId()and pass it down to repositories - Repositories add
AND tenant_id = ?to their queries when$tenantId !== null
// Route — add TenantMiddleware to enable scoping
$router->group([AuthMiddleware::class, TenantMiddleware::class], function ($router) {
$router->get('/productos', [ProductoController::class, 'index']);
});
// Controller
public function index(Request $request): Response
{
return Response::success($this->service->getAllForTenant($request->tenantId()));
}
// Repository — conditional scoping
public function findAll(?string $tenantId = null): array
{
$sql = 'SELECT * FROM productos';
$params = [];
if ($tenantId !== null) {
$sql .= ' WHERE tenant_id = ?';
$params[] = $tenantId;
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
Running without multi-tenancy¶
Simply don't add TenantMiddleware to any route. The tenant_id column in usuarios can be omitted. Repositories receive null and skip the tenant filter. No other changes needed.
Admin impersonation across tenants¶
An admin can only impersonate users within their own tenant. Attempting cross-tenant impersonation throws AuthException(403). Passing $adminTenantId = null skips this check (internal use only — the route always passes the real tenant ID via TenantMiddleware).